Chapter 2

Type Inference and Annotation Boundaries

What TypeScript Infers Automatically

The TypeScript compiler is better at inferring types than you might expect. Annotations you don't need to write are noise โ€” they clutter code and create extra work during refactoring.

Variable initialization โ€” don't annotate

// Redundant โ€” the compiler already knows
const name: string = "Alice";
const count: number = 42;
const active: boolean = true;

// Correct: let the compiler infer
const name = "Alice";    // inferred: string
const count = 42;        // inferred: number
const active = true;     // inferred: boolean (not true!)

Function return values โ€” usually skip it

// Compiler infers return type: number
function add(a: number, b: number) {
  return a + b;
}

// Compiler infers return type: string | null
function findUser(id: number) {
  if (id === 0) return null;
  return `user_${id}`;
}

Object literals โ€” skip it

// Compiler infers the full object shape
const config = {
  host: "localhost",
  port: 3000,
  debug: true,
};
// Inferred: { host: string; port: number; debug: boolean }

5 Situations Where You Must Annotate

Case 1: Function parameters

The most common case. Parameters have no initial value, so the compiler can't infer them.

// Bad: a and b are inferred as any (error in strict mode)
function add(a, b) {
  return a + b;
}

// Correct
function add(a: number, b: number): number {
  return a + b;
}

Case 2: Function return type (as a contract)

When a function is a public API or needs an explicit contract, annotating the return type prevents the implementation from accidentally drifting.

// No return type: if you forget a return branch,
// the inferred type becomes string | undefined and callers must handle it
function getUserName(id: number) {
  const users: Record<number, string> = { 1: "Alice", 2: "Bob" };
  if (users[id]) return users[id];
}

// With return type: compiler forces you to cover all branches
function getUserName(id: number): string {
  const users: Record<number, string> = { 1: "Alice", 2: "Bob" };
  return users[id] ?? "Unknown"; // must return string, not undefined
}

Case 3: Declare first, assign later

// Compiler infers user as any โ€” type protection lost
let user;
user = fetchUser(); // user is any here

// Correct: specify the type at declaration
let user: User;
user = fetchUser(); // compiler checks that fetchUser() returns User

Case 4: Object parameters that need an explicit shape

// Callers don't know what fields to pass
function createUser(options) { // options is any
  return { name: options.name, role: options.role };
}

// Correct: define the contract explicitly
interface CreateUserOptions {
  name: string;
  role: "admin" | "user";
  email?: string;
}

function createUser(options: CreateUserOptions) {
  return { name: options.name, role: options.role };
}

Case 5: Empty array initialization

// Empty array is inferred as never[] โ€” push will fail
const items = [];
items.push("hello"); // Error: Argument of type 'string' is not assignable to 'never'

// Correct: specify element type
const items: string[] = [];
items.push("hello"); // โœ…

Type Widening

TypeScript automatically "widens" inferred types. This behavior is sometimes surprising.

// let declaration: type is widened to string
let status = "active";  // inferred: string, not "active"
status = "inactive";    // โœ… valid

// const declaration: type is narrowed to the literal
const status = "active";  // inferred: "active" (literal type)

The practical problem:

function setStatus(status: "active" | "inactive") {
  console.log(status);
}

let s = "active";    // s is string, not "active"
setStatus(s);        // Error: string is not assignable to "active" | "inactive"

const s = "active";  // s is "active"
setStatus(s);        // โœ…

Use as const to lock in literal types:

const config = {
  method: "GET",
  timeout: 3000,
};
// Inferred: { method: string; timeout: number }
// method is string โ€” passing to a param expecting "GET" | "POST" will fail

const config = {
  method: "GET",
  timeout: 3000,
} as const;
// Inferred: { readonly method: "GET"; readonly timeout: 3000 }
// Now method is the literal type "GET" โ€” fully type-safe

satisfies: Best of Both Worlds

The satisfies operator (TypeScript 4.9) validates structure against a type while preserving the inferred concrete type.

type Colors = "red" | "green" | "blue";
type Palette = Record<Colors, string | [number, number, number]>;

// With type annotation: concrete type info is lost
const palette: Palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
};
palette.green.toUpperCase(); // Error: string | [number,number,number] has no toUpperCase

// With satisfies: validates structure AND preserves inferred types
const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
} satisfies Palette;
palette.green.toUpperCase(); // โœ… compiler knows green is string
palette.red[0];              // โœ… compiler knows red is an array

Where to Stop Trusting Inference

Don't trust it here:

// JSON.parse returns any โ€” dangerous, annotate immediately
const data = JSON.parse(response.body); // any
data.user.name; // zero type protection, runtime crash possible

// Correct: use zod or a type assertion (zod covered in Chapter 18)
const data = JSON.parse(response.body) as ApiResponse;
// Or more explicitly:
const data: ApiResponse = JSON.parse(response.body);

The fetch trap โ€” response.json() returns any:

// response.json() returns Promise<any> โ€” same danger
const res = await fetch("/api/users");
const users = await res.json(); // any

// Correct: wrap in a typed fetch helper
async function typedFetch<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

const users = await typedFetch<User[]>("/api/users");
// users is User[] โ€” full type protection

Key Takeaways

Situation Recommendation
Variable initialization Skip โ€” let the compiler infer
Function parameters Always annotate
Function return type Annotate public APIs; skip private functions
Empty array Always annotate the element type
Declare before assign Always annotate
Literal precision needed Use const or as const
Validate structure + preserve inference Use satisfies
JSON.parse / fetch Immediately annotate the target type

Next chapter: Union types, intersection types, and literal types โ€” how Discriminated Unions replace entire chains of if-else checks.

Rate this chapter
4.6  / 5  (95 ratings)

๐Ÿ’ฌ Comments