2.5 Type Inference
TypeScript's type inference is the compiler's ability to analyze code context and determine types on its own without the developer explicitly stating them. Thanks to a well-functioning inference system, type safety can be maintained without writing type annotations everywhere. Conversely, understanding when inference behaves differently from your intent lets you avoid unnecessary type errors.
This chapter systematically covers everything from basic inference to contextual typing, best common types, type widening and narrowing, locking in literal types with as const, and when annotations are actually needed.
Basic Type Inference — Variable Initialization
When a variable is declared and assigned an initial value at the same time, TypeScript automatically determines the type of the variable from that value.
const greeting = "Hello, TypeScript"; // string
const count = 42; // number
const pi = 3.14159; // number
const isReady = true; // boolean
const nothing = null; // null
const missing = undefined; // undefined
// Error on wrong type assignment later
let message = "initial";
message = 123; // error: Type 'number' is not assignable to type 'string'
Inference Difference: let vs const
Variables declared with const cannot be reassigned, so they are inferred as literal types. Variables declared with let can change to other values later, so they are inferred as the broader primitive type.
const constStr = "hello"; // type: "hello" (literal type)
let letStr = "hello"; // type: string (widened type)
const constNum = 42; // type: 42 (literal type)
let letNum = 42; // type: number (widened type)
const constBool = true; // type: true (literal type)
let letBool = true; // type: boolean (widened type)
This difference matters in situations where a literal type is needed.
type Direction = "north" | "south" | "east" | "west";
const dir1 = "north"; // type: "north" — assignable to Direction
let dir2 = "north"; // type: string — not directly assignable to Direction
function move(d: Direction): void { /* ... */ }
move(dir1); // OK
move(dir2); // error: Argument of type 'string' is not assignable to parameter of type 'Direction'
Function Return Type Inference
The return type is automatically determined by analyzing the function body.
// No return type annotation — inferred as string
function getGreeting(name: string) {
return `Hello, ${name}!`;
}
// inferred: (name: string) => string
// Conditional return — inferred as union type
function divide(a: number, b: number) {
if (b === 0) return null;
return a / b;
}
// inferred: (a: number, b: number) => number | null
// Multiple return paths — union of all paths
function classify(n: number) {
if (n > 0) return "positive";
if (n < 0) return "negative";
return "zero";
}
// inferred: (n: number) => "positive" | "negative" | "zero"
Contextual Typing
Type inference usually flows from right (value) to left (variable). However, contextual typing flows in the opposite direction — the type information on the left determines the type of the expression on the right.
Event Handlers
// Since the type of window.onmousedown is already defined,
// there is no need to annotate the type of the callback parameter mouseEvent separately
window.onmousedown = function (mouseEvent) {
// mouseEvent is contextually typed as MouseEvent
console.log(mouseEvent.button); // OK — button exists on MouseEvent
console.log(mouseEvent.kangaroo); // error: Property 'kangaroo' does not exist on type 'MouseEvent'
};
// Without context, standalone function parameter is inferred as any
const handler = function (mouseEvent) {
// With noImplicitAny option, error: Parameter 'mouseEvent' implicitly has an 'any' type
};
Callback Parameters
const numbers = [1, 2, 3, 4, 5];
// value is contextually inferred as number in the forEach callback
numbers.forEach((value) => {
console.log(value.toFixed(2)); // OK — value is number
});
// map callback — value: number, index: number, array: number[]
const doubled = numbers.map((value) => value * 2);
// doubled: number[]
Methods in Object Types
interface EventHandlers {
onClick: (event: MouseEvent) => void;
onKeyDown: (event: KeyboardEvent) => void;
}
const handlers: EventHandlers = {
// event is contextually typed as MouseEvent
onClick(event) {
console.log(event.clientX, event.clientY);
},
// event is contextually typed as KeyboardEvent
onKeyDown(event) {
console.log(event.key, event.code);
},
};
Best Common Type
In expressions where multiple types coexist — such as array literals composed of multiple values or conditional returns — TypeScript calculates the best common type that can accommodate all values.
// number and null — (number | null)[]
const arr1 = [1, 2, null];
// type: (number | null)[]
// string and number — (string | number)[]
const arr2 = ["hello", 42, "world"];
// type: (string | number)[]
// Class hierarchy — inferred as common parent type
class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
class Cat extends Animal { indoor: boolean = true; }
const pets = [new Dog(), new Cat()];
// type: (Dog | Cat)[] — inferred as union, not Animal!
// TypeScript finds the best common type among declared types; it does not automatically select the parent class
Forcing a Common Type with Explicit Annotations
When a broader type than what the compiler infers is needed, use an explicit annotation.
class Animal { move(): void { console.log("move"); } }
class Dog extends Animal { bark(): void { console.log("woof"); } }
class Cat extends Animal { meow(): void { console.log("meow"); } }
// Force Animal[] with explicit annotation
const animals: Animal[] = [new Dog(), new Cat()];
animals[0].move(); // OK
// animals[0].bark(); // error: Property 'bark' does not exist on type 'Animal'
// Force type through a generic instead of a type assertion
function createArray<T>(...items: T[]): T[] {
return items;
}
const dogs = createArray(new Dog(), new Dog()); // Dog[]
Type Widening
When a literal value is assigned to a variable declared with let, TypeScript decides that another value may be assigned later and widens the inference from the literal type to the corresponding primitive type.
let x = "hello"; // widened to string (not "hello")
let n = 42; // widened to number (not 42)
let b = true; // widened to boolean (not true)
x = "world"; // OK — allowed because it's a string
n = 99; // OK — allowed because it's a number
null Widening
A variable initialized with null or undefined without a type annotation is inferred as type null or undefined. To assign a value of another type later, a union type annotation is needed.
let value = null; // type: null
value = "hello"; // error: Type 'string' is not assignable to type 'null'
// Must use a union type explicitly
let flexible: string | null = null;
flexible = "hello"; // OK
When Widening Becomes a Problem
type Status = "active" | "inactive" | "pending";
// Intent: use as a Status type variable
let status = "active"; // inferred: string (widened)
function setStatus(s: Status): void { /* ... */ }
setStatus(status); // error: Argument of type 'string' is not assignable to parameter of type 'Status'
// Solution 1: type annotation
let status2: Status = "active"; // fixed as Status type
setStatus(status2); // OK
// Solution 2: as const
const status3 = "active" as const; // type: "active"
setStatus(status3); // OK
Type Narrowing
When a union type or unknown type variable is checked with conditionals, type guards, or specific operators, the variable's type becomes more specific (narrowed) within that block.
typeof Guard
function processValue(value: string | number | boolean): string {
if (typeof value === "string") {
// value: string in this block
return value.toUpperCase();
}
if (typeof value === "number") {
// value: number in this block
return value.toFixed(2);
}
// value: boolean in this block (remainder after string and number)
return value ? "true" : "false";
}
instanceof Guard
function formatDate(value: Date | string): string {
if (value instanceof Date) {
// value: Date in this block
return value.toLocaleDateString("en-US");
}
// value: string in this block
return new Date(value).toLocaleDateString("en-US");
}
in Operator Guard
Narrows the type based on the existence of a specific property on an object.
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
type Shape = Circle | Rectangle;
function getArea(shape: Shape): number {
if ("radius" in shape) {
// shape: Circle in this block
return Math.PI * shape.radius ** 2;
}
// shape: Rectangle in this block
return shape.width * shape.height;
}
Discriminant Property Guard
When a union type has a common literal type property (kind, type, tag, etc.), its value can be used to narrow the type. This is called a discriminated union.
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; code: number; message: string }
| { status: "loading" };
function handleResult<T>(result: Result<T>): void {
switch (result.status) {
case "success":
// result: { status: "success"; data: T }
console.log("Data:", result.data);
break;
case "error":
// result: { status: "error"; code: number; message: string }
console.error(`${result.code}: ${result.message}`);
break;
case "loading":
// result: { status: "loading" }
console.log("Loading...");
break;
}
}
Equality and null Narrowing
function greet(name: string | null | undefined): string {
if (name == null) {
// == null filters out both null and undefined
// name: null | undefined in this block
return "Hello, Guest!";
}
// name: string in this block
return `Hello, ${name}!`;
}
User-Defined Type Guards (Type Predicates)
Package complex structural checks into reusable functions.
interface ApiUser {
id: number;
username: string;
email: string;
}
function isApiUser(value: unknown): value is ApiUser {
return (
typeof value === "object" &&
value !== null &&
typeof (value as ApiUser).id === "number" &&
typeof (value as ApiUser).username === "string" &&
typeof (value as ApiUser).email === "string"
);
}
function processApiResponse(raw: unknown): string {
if (isApiUser(raw)) {
// raw: ApiUser in this block
return `${raw.username} (${raw.email})`;
}
return "Invalid response";
}
as const — Locking Literal Types
as const locks an expression's type to the narrowest possible literal types. Unlike const declarations, it applies to all nested values of objects and arrays.
// as const on an object
const config = {
host: "localhost",
port: 3000,
ssl: false,
} as const;
// config type:
// {
// readonly host: "localhost";
// readonly port: 3000;
// readonly ssl: false;
// }
config.port = 8080; // error: Cannot assign to 'port' because it is a read-only property
// Without as const
const configMutable = {
host: "localhost",
port: 3000,
};
// configMutable type: { host: string; port: number }
as const with Arrays and Tuples
// Array → converted to readonly tuple
const ALLOWED_METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
// type: readonly ["GET", "POST", "PUT", "DELETE"]
type AllowedMethod = typeof ALLOWED_METHODS[number];
// "GET" | "POST" | "PUT" | "DELETE"
// Force function return as a tuple
function getCoord() {
return [10, 20] as const;
}
// return type: readonly [10, 20]
const [x, y] = getCoord();
// x: 10, y: 20 (literal types, not number)
as const and Nested Objects
as const propagates deeply. All nested properties become readonly literal types.
const ROUTES = {
home: "/",
users: {
list: "/users",
create: "/users/new",
detail: (id: number) => `/users/${id}` as const,
},
posts: {
list: "/posts",
detail: "/posts/:id",
},
} as const;
type HomeRoute = typeof ROUTES.home; // "/"
type UserListRoute = typeof ROUTES.users.list; // "/users"
// Union type of all static route values
type StaticRoute = typeof ROUTES.home | typeof ROUTES.users.list | typeof ROUTES.users.create
| typeof ROUTES.posts.list | typeof ROUTES.posts.detail;
// "/" | "/users" | "/users/new" | "/posts" | "/posts/:id"
Guidelines: When to Annotate vs When to Infer
The criteria for deciding when to rely on inference and when an explicit annotation is needed.
When You Can Rely on Inference
// 1. Variables with initial values
const name = "Alice"; // inferred as string
const items = [1, 2, 3]; // inferred as number[]
// 2. Functions with obvious return types
function double(n: number) {
return n * 2; // return type inferred as number
}
// 3. forEach, map, filter and other callbacks
const names = ["Alice", "Bob"];
names.forEach((name) => console.log(name.toUpperCase())); // name: string inferred
When Annotations Are Needed
// 1. Variables assigned later
let result: string;
if (Math.random() > 0.5) {
result = "heads";
} else {
result = "tails";
}
// 2. Function parameters — always annotate
function process(data: unknown): void { /* ... */ }
// 3. When the return type is complex or intent should be clear
function parseUser(raw: unknown): ApiUser | null {
// Explicitly declaring the return type makes the function contract clear
if (!isApiUser(raw)) return null;
return raw;
}
// 4. When inference differs from intent
const status: Status = "active"; // need Status literal type, not string
// 5. Empty array initialization
const tags: string[] = []; // [] alone infers as never[]
tags.push("typescript"); // OK
// 6. Use explicit interfaces for complex object types
const user: UserProfile = {
id: 1,
name: "Alice",
email: "alice@example.com",
};
Practical Example 1: API Response Type Inference
// Fetch wrapper — delegates return type to a generic
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
// Usage: return type is automatically inferred
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
async function getPost(id: number): Promise<Post> {
// Inferred as Post type
return fetchJson<Post>(`https://jsonplaceholder.typicode.com/posts/${id}`);
}
async function main() {
const post = await getPost(1);
// post.title: string — type-safe access
console.log(post.title.toUpperCase());
}
// Generic repository handling multiple typed resources
class Repository<T extends { id: number }> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
// item is inferred as T
return this.items.find((item) => item.id === id);
}
getAll(): readonly T[] {
return this.items;
}
}
const postRepo = new Repository<Post>();
postRepo.add({ id: 1, title: "Hello", body: "World", userId: 1 });
const found = postRepo.findById(1); // Post | undefined
Practical Example 2: Configuration Object as const Pattern
// Manage the entire app config immutably with as const
const APP_CONFIG = {
api: {
baseUrl: "https://api.example.com",
version: "v2",
timeout: 5000,
},
auth: {
tokenKey: "auth_token",
refreshKey: "refresh_token",
expiryMinutes: 60,
},
features: {
darkMode: true,
analytics: false,
betaFeatures: false,
},
supportedLocales: ["en", "es", "fr", "de"] as const,
} as const;
// Type extraction
type AppConfig = typeof APP_CONFIG;
type ApiConfig = typeof APP_CONFIG.api;
type SupportedLocale = typeof APP_CONFIG.supportedLocales[number];
// "en" | "es" | "fr" | "de"
function setLocale(locale: SupportedLocale): void {
console.log(`Locale changed: ${locale}`);
}
setLocale("en"); // OK
setLocale("zh"); // error: Argument of type '"zh"' is not assignable
// Type-safe access to config values
function getApiUrl(path: string): string {
return `${APP_CONFIG.api.baseUrl}/${APP_CONFIG.api.version}/${path}`;
}
// Feature flag check
function isFeatureEnabled(
feature: keyof typeof APP_CONFIG.features
): boolean {
return APP_CONFIG.features[feature];
}
console.log(isFeatureEnabled("darkMode")); // true
console.log(isFeatureEnabled("analytics")); // false
// console.log(isFeatureEnabled("unknown")); // error: not a valid key
Pro Tips
Tip 1: Use the satisfies operator for inference and validation simultaneously (TypeScript 4.9+)
type Palette = {
red: string | [number, number, number];
green: string | [number, number, number];
blue: string | [number, number, number];
};
// Using as const alone doesn't validate the type
// satisfies validates the type while preserving literal type inference
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies Palette;
// palette.red is inferred as [number, number, number] (not string | [...] union)
console.log(palette.red[0]); // OK: 255
console.log(palette.green.toUpperCase()); // OK: "#00FF00"
// Wrong values produce errors
const wrong = {
red: [255, 0, 0],
green: "#00ff00",
// blue: 300, // error: number is not assignable to string | [number, number, number]
} satisfies Palette;
Tip 2: Extract inferred types with infer in conditional types
// Extract function return type
type ReturnType<T extends (...args: unknown[]) => unknown> =
T extends (...args: unknown[]) => infer R ? R : never;
type DoubleReturn = ReturnType<typeof double>; // number
// Extract the inner type of a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
type PostData = Awaited<ReturnType<typeof getPost>>; // Post
Tip 3: Reuse inferred types with typeof
// Instead of writing out long type definitions, use inferred types
function createDefaultUser() {
return {
id: 0,
name: "",
email: "",
role: "guest" as const,
createdAt: new Date(),
};
}
// Use the function's return type as a new type alias
type DefaultUser = ReturnType<typeof createDefaultUser>;
Tip 4: What to check when narrowing doesn't work as expected
// Problem: type changes after property access
function processUser(user: { name: string | null }) {
if (user.name !== null) {
// At this point user.name is thought to be narrowed to string, but...
setTimeout(() => {
// Inside the callback, user.name can become string | null again
console.log(user.name?.toUpperCase()); // optional chaining required
}, 100);
}
}
// Solution: store the narrowed value in a local variable
function processUserSafe(user: { name: string | null }) {
const name = user.name; // copy to local variable
if (name !== null) {
setTimeout(() => {
console.log(name.toUpperCase()); // OK — name is fixed as string
}, 100);
}
}
Tip 5: Think of explicit type annotations as an investment when inference fails
// When inference fails in complex generic chains, add explicit types at intermediate steps
const data = fetchAndTransform(); // inferred: SomeComplexType<...>
// Explicit type annotation — an investment for future readers (including yourself)
const typedData: ProcessedRecord[] = fetchAndTransform();
declare function fetchAndTransform(): ProcessedRecord[];
interface ProcessedRecord { id: number; value: string; }
Summary Table
| Inference Type | How It Works | Example |
|---|---|---|
| Variable initialization | Type determined from initial value | let x = 42 → number |
| const literal inference | Locked to literal type | const x = 42 → 42 |
| Function return inference | Analyzes return expressions | return n * 2 → number |
| Contextual typing | Left (context) → Right (value) direction | Event handler callbacks |
| Best common type | Union of multiple types | [1, "a"] → (number | string)[] |
| Type widening | Literal → primitive type | let x = "hi" → string |
| Type narrowing | Scope reduction via conditionals | typeof x === "string" |
| as const locking | All values become literal types | {a: 1} as const → {readonly a: 1} |
| satisfies | Validation + literal preservation | TS 4.9+ recommended pattern |
| Annotation Needed | Situation |
|---|---|
| Not needed | Variables with initial values, simple function returns, callback parameters |
| Needed | Function parameters, variables assigned later, empty arrays, complex return types |
| Recommended | Public API function return types, complex object types |
The next chapter covers interfaces. We will look at how to define object types, optional properties, readonly properties, index signatures, function type interfaces, and interface merging (declaration merging) — the core structures of the TypeScript type system.