TypeScript — Type Inference
TypeScript's type inference is one of its most powerful features. Rather than requiring explicit annotations everywhere, the compiler analyzes your code and figures out types automatically. Understanding how inference works helps you write less boilerplate while maintaining full type safety.
Inference happens in multiple directions: from literals upward, from context downward, and from control flow narrowing. Mastering these patterns is key to writing idiomatic TypeScript.
The simplest form of inference is from initialization values. When you assign a value to a variable, TypeScript infers the type from that value.
| 1 | // Variable inference — from initialization |
| 2 | let x = 42; // inferred: number |
| 3 | let s = "hello"; // inferred: string |
| 4 | let flag = true; // inferred: boolean |
| 5 | let nothing = null; // inferred: null |
| 6 | let undef = undefined; // inferred: undefined |
| 7 | |
| 8 | // Array inference — from elements |
| 9 | let nums = [1, 2, 3]; // inferred: number[] |
| 10 | let mixed = [1, "two", 3]; // inferred: (string | number)[] |
| 11 | |
| 12 | // Object inference — from shape |
| 13 | let user = { |
| 14 | name: "Alice", |
| 15 | age: 30, |
| 16 | active: true, |
| 17 | }; |
| 18 | // inferred: { name: string; age: number; active: boolean } |
| 19 | |
| 20 | // Return type inference |
| 21 | function add(a: number, b: number) { |
| 22 | return a + b; // return type inferred as number |
| 23 | } |
| 24 | |
| 25 | // Generic inference — from arguments |
| 26 | function identity<T>(x: T): T { |
| 27 | return x; |
| 28 | } |
| 29 | |
| 30 | let result = identity(42); // T inferred as number |
| 31 | let result2 = identity("hi"); // T inferred as string |
info
When TypeScript needs to find a single type that covers multiple values (like in array literals or ternary expressions), it computes the "best common type" — the most specific type that all values are assignable to.
| 1 | // Array with mixed types → union |
| 2 | let arr = [1, "two", true]; |
| 3 | // inferred: (string | number | boolean)[] |
| 4 | |
| 5 | // Ternary expression → best common type |
| 6 | let value = Math.random() > 0.5 ? "hello" : 42; |
| 7 | // inferred: string | number |
| 8 | |
| 9 | // Nested objects → structural type with unions |
| 10 | let data = [ |
| 11 | { id: 1, name: "Alice" }, |
| 12 | { id: 2, email: "bob@example.com" }, // different shape |
| 13 | ]; |
| 14 | // inferred: ({ id: number; name: string } | { id: number; email: string })[] |
| 15 | |
| 16 | // Return type inference from multiple returns |
| 17 | function parse(input: string) { |
| 18 | if (input === "null") return null; |
| 19 | if (input === "undefined") return undefined; |
| 20 | return input; |
| 21 | } |
| 22 | // inferred return type: string | null | undefined |
| 23 | |
| 24 | // Best common type with class hierarchies |
| 25 | class Animal {} |
| 26 | class Dog extends Animal {} |
| 27 | class Cat extends Animal {} |
| 28 | |
| 29 | let pets = [new Dog(), new Cat()]; |
| 30 | // inferred: (Dog | Cat)[] — not Animal[] (most specific) |
Contextual typing works in the opposite direction — when the expected type is known, TypeScript uses it to infer the types of expressions. This happens with callbacks, event handlers, and typed containers.
| 1 | // Callback parameter inference |
| 2 | let numbers = [1, 2, 3]; |
| 3 | |
| 4 | // TypeScript knows callback params from the array type |
| 5 | numbers.map(x => x * 2); // x inferred as number |
| 6 | numbers.filter(x => x > 1); // x inferred as number |
| 7 | |
| 8 | // Event handler contextual typing |
| 9 | document.addEventListener("click", (event) => { |
| 10 | // event inferred as MouseEvent (from addEventListener signature) |
| 11 | console.log(event.clientX, event.clientY); |
| 12 | }); |
| 13 | |
| 14 | // Typed callback parameters |
| 15 | type Callback = (data: string, index: number) => void; |
| 16 | |
| 17 | function process(items: string[], cb: Callback) { |
| 18 | items.forEach(cb); // cb params inferred from Callback type |
| 19 | } |
| 20 | |
| 21 | process(["a", "b"], (data, index) => { |
| 22 | // data inferred as string, index inferred as number |
| 23 | console.log(`${index}: ${data}`); |
| 24 | }); |
| 25 | |
| 26 | // Object method inference |
| 27 | interface MathOps { |
| 28 | add(a: number, b: number): number; |
| 29 | subtract(a: number, b: number): number; |
| 30 | } |
| 31 | |
| 32 | const ops: MathOps = { |
| 33 | add: (a, b) => a + b, // a, b inferred from MathOps |
| 34 | subtract: (a, b) => a - b, // a, b inferred from MathOps |
| 35 | }; |
| 36 | |
| 37 | // Return type from interface |
| 38 | interface Repository { |
| 39 | findById(id: string): User | null; |
| 40 | } |
| 41 | |
| 42 | const repo: Repository = { |
| 43 | findById: (id) => { |
| 44 | // id inferred as string, return type must be User | null |
| 45 | return null; |
| 46 | }, |
| 47 | }; |
best practice
By default, TypeScript widens literal values to their base types. A variable initialized with "hello" is inferred as string, not "hello". This is intentional — it allows reassignment. Use const or as const to keep literal types.
| 1 | // let → widened to base type |
| 2 | let direction = "north"; |
| 3 | // inferred: string (not "north") |
| 4 | direction = "south"; // OK — string allows any string |
| 5 | |
| 6 | // const → literal type preserved |
| 7 | const direction2 = "north"; |
| 8 | // inferred: "north" (not string) |
| 9 | // direction2 = "south"; // Error: Cannot assign to 'direction2' |
| 10 | |
| 11 | // const with object → properties still widened |
| 12 | const config = { host: "localhost", port: 3000 }; |
| 13 | // inferred: { host: string; port: number } |
| 14 | config.host = "production"; // OK — host is string |
| 15 | |
| 16 | // as const → deeply readonly with literal types |
| 17 | const config2 = { host: "localhost", port: 3000 } as const; |
| 18 | // inferred: { readonly host: "localhost"; readonly port: 3000 } |
| 19 | // config2.host = "production"; // Error |
| 20 | |
| 21 | // as const on arrays → readonly tuple |
| 22 | const coords = [1, 2, 3] as const; |
| 23 | // inferred: readonly [1, 2, 3] |
| 24 | |
| 25 | // Useful for: route parameters, status codes, API endpoints |
| 26 | const API_ROUTES = { |
| 27 | users: "/api/users", |
| 28 | posts: "/api/posts", |
| 29 | } as const; |
| 30 | // typeof API_ROUTES["users"] → "/api/users" (literal, not string) |
as const is a special assertion that tells TypeScript to infer the narrowest possible type — literal types for primitives, readonly for collections, and const for objects.
| 1 | // Without as const — widened types |
| 2 | let colors = ["red", "green", "blue"]; |
| 3 | // inferred: string[] |
| 4 | colors.push("yellow"); // OK |
| 5 | |
| 6 | // With as const — literal readonly types |
| 7 | let colors2 = ["red", "green", "blue"] as const; |
| 8 | // inferred: readonly ["red", "green", "blue"] |
| 9 | // colors2.push("yellow"); // Error: Property 'push' does not exist |
| 10 | |
| 11 | // Enum-like pattern with as const |
| 12 | const HttpStatus = { |
| 13 | OK: 200, |
| 14 | NotFound: 404, |
| 15 | ServerError: 500, |
| 16 | } as const; |
| 17 | |
| 18 | type Status = (typeof HttpStatus)[keyof typeof HttpStatus]; |
| 19 | // type Status = 200 | 404 | 500 |
| 20 | |
| 21 | function handleStatus(status: Status) { |
| 22 | switch (status) { |
| 23 | case HttpStatus.OK: |
| 24 | return "success"; |
| 25 | case HttpStatus.NotFound: |
| 26 | return "not found"; |
| 27 | case HttpStatus.ServerError: |
| 28 | return "error"; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // Union from literal array |
| 33 | const DIRECTIONS = ["north", "south", "east", "west"] as const; |
| 34 | type Direction = (typeof DIRECTIONS)[number]; |
| 35 | // type Direction = "north" | "south" | "east" | "west" |
| 36 | |
| 37 | // Function that only accepts specific values |
| 38 | function move(direction: Direction): void { |
| 39 | console.log(`Moving ${direction}`); |
| 40 | } |
| 41 | |
| 42 | move("north"); // OK |
| 43 | // move("up"); // Error: "up" is not assignable to Direction |
info
Type widening is the process by which TypeScript expands a narrow type to a broader one. It happens automatically in most cases and is the default behavior for variables.
| 1 | // Default widening |
| 2 | let x = "hello"; // widened from "hello" to string |
| 3 | x = "world"; // OK — string allows any string |
| 4 | |
| 5 | // Widening in conditionals |
| 6 | function process(value: string | number) { |
| 7 | let result = value; // widened: string | number |
| 8 | if (typeof value === "string") { |
| 9 | result = value; // narrowed: string |
| 10 | } |
| 11 | return result; // string | number (widened back) |
| 12 | } |
| 13 | |
| 14 | // NoWideningLiteral — prevents widening (advanced) |
| 15 | type NoWidening<T> = T extends string ? string : T; |
| 16 | |
| 17 | // Widening with default values |
| 18 | function greet(name: string = "world") { |
| 19 | // name: string — default value doesn't narrow |
| 20 | } |
| 21 | |
| 22 | // Widening with union types |
| 23 | type Color = "red" | "green" | "blue"; |
| 24 | |
| 25 | let c: Color = "red"; // OK — "red" is assignable to Color |
| 26 | let d = "red"; // widened to string — not Color! |
| 27 | |
| 28 | // To keep literal type, use: |
| 29 | let e: Color = "red" as Color; // explicit annotation |
| 30 | let f = "red" as const; // literal type "red" |
Narrowing is the opposite of widening — TypeScript progressively refines types inside control flow branches. This is how you safely work with union types.
typeof Narrowing
| 1 | function process(value: string | number | boolean) { |
| 2 | if (typeof value === "string") { |
| 3 | // TS knows: value is string |
| 4 | return value.toUpperCase(); |
| 5 | } |
| 6 | if (typeof value === "number") { |
| 7 | // TS knows: value is number |
| 8 | return value.toFixed(2); |
| 9 | } |
| 10 | // TS knows: value is boolean |
| 11 | return value ? "yes" : "no"; |
| 12 | } |
instanceof Narrowing
| 1 | function handleError(error: Error | string) { |
| 2 | if (error instanceof Error) { |
| 3 | // TS knows: error is Error |
| 4 | console.log(error.message); |
| 5 | console.log(error.stack); |
| 6 | } else { |
| 7 | // TS knows: error is string |
| 8 | console.log(error); |
| 9 | } |
| 10 | } |
Discriminated Unions
| 1 | type Shape = |
| 2 | | { kind: "circle"; radius: number } |
| 3 | | { kind: "rectangle"; width: number; height: number } |
| 4 | | { kind: "triangle"; base: number; height: number }; |
| 5 | |
| 6 | function area(shape: Shape): number { |
| 7 | switch (shape.kind) { |
| 8 | case "circle": |
| 9 | return Math.PI * shape.radius ** 2; |
| 10 | case "rectangle": |
| 11 | return shape.width * shape.height; |
| 12 | case "triangle": |
| 13 | return (shape.base * shape.height) / 2; |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | // instanceof with classes |
| 18 | class Dog { |
| 19 | bark() { return "Woof!"; } |
| 20 | } |
| 21 | |
| 22 | class Cat { |
| 23 | meow() { return "Meow!"; } |
| 24 | } |
| 25 | |
| 26 | function makeSound(animal: Dog | Cat) { |
| 27 | if (animal instanceof Dog) { |
| 28 | return animal.bark(); // TS knows: Dog |
| 29 | } |
| 30 | return animal.meow(); // TS knows: Cat |
| 31 | } |
Type Predicates
| 1 | // Custom type guard — "is" keyword |
| 2 | function isString(value: unknown): value is string { |
| 3 | return typeof value === "string"; |
| 4 | } |
| 5 | |
| 6 | function process(value: unknown) { |
| 7 | if (isString(value)) { |
| 8 | // TS knows: value is string |
| 9 | return value.toUpperCase(); |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | // Predicate with class check |
| 14 | function isDog(animal: Dog | Cat): animal is Dog { |
| 15 | return animal instanceof Dog; |
| 16 | } |
| 17 | |
| 18 | // Filter with type predicate |
| 19 | let mixed: (string | number)[] = [1, "hello", 2, "world"]; |
| 20 | let strings = mixed.filter(isString); // inferred: string[] |
| 21 | |
| 22 | // Assertion function (throws if wrong) |
| 23 | function assertIsString(value: unknown): asserts value is string { |
| 24 | if (typeof value !== "string") { |
| 25 | throw new Error("Expected string"); |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | function example(value: unknown) { |
| 30 | assertIsString(value); |
| 31 | // TS knows: value is string (after assertion) |
| 32 | console.log(value.toUpperCase()); |
| 33 | } |
best practice