|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/inference
$cat docs/typescript-—-type-inference.md
updated Recently·10 min read·published

TypeScript — Type Inference

TypeScriptBeginner
Introduction

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.

Basic Inference

The simplest form of inference is from initialization values. When you assign a value to a variable, TypeScript infers the type from that value.

basic_inference.ts
TypeScript
1// Variable inference — from initialization
2let x = 42; // inferred: number
3let s = "hello"; // inferred: string
4let flag = true; // inferred: boolean
5let nothing = null; // inferred: null
6let undef = undefined; // inferred: undefined
7
8// Array inference — from elements
9let nums = [1, 2, 3]; // inferred: number[]
10let mixed = [1, "two", 3]; // inferred: (string | number)[]
11
12// Object inference — from shape
13let user = {
14 name: "Alice",
15 age: 30,
16 active: true,
17};
18// inferred: { name: string; age: number; active: boolean }
19
20// Return type inference
21function add(a: number, b: number) {
22 return a + b; // return type inferred as number
23}
24
25// Generic inference — from arguments
26function identity<T>(x: T): T {
27 return x;
28}
29
30let result = identity(42); // T inferred as number
31let result2 = identity("hi"); // T inferred as string

info

Inference works bidirectionally — from values upward and from context downward. TypeScript uses the most specific type it can determine.
Best Common Type

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.

best_common_type.ts
TypeScript
1// Array with mixed types → union
2let arr = [1, "two", true];
3// inferred: (string | number | boolean)[]
4
5// Ternary expression → best common type
6let value = Math.random() > 0.5 ? "hello" : 42;
7// inferred: string | number
8
9// Nested objects → structural type with unions
10let 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
17function 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
25class Animal {}
26class Dog extends Animal {}
27class Cat extends Animal {}
28
29let pets = [new Dog(), new Cat()];
30// inferred: (Dog | Cat)[] — not Animal[] (most specific)
Contextual Typing

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.

contextual_typing.ts
TypeScript
1// Callback parameter inference
2let numbers = [1, 2, 3];
3
4// TypeScript knows callback params from the array type
5numbers.map(x => x * 2); // x inferred as number
6numbers.filter(x => x > 1); // x inferred as number
7
8// Event handler contextual typing
9document.addEventListener("click", (event) => {
10 // event inferred as MouseEvent (from addEventListener signature)
11 console.log(event.clientX, event.clientY);
12});
13
14// Typed callback parameters
15type Callback = (data: string, index: number) => void;
16
17function process(items: string[], cb: Callback) {
18 items.forEach(cb); // cb params inferred from Callback type
19}
20
21process(["a", "b"], (data, index) => {
22 // data inferred as string, index inferred as number
23 console.log(`${index}: ${data}`);
24});
25
26// Object method inference
27interface MathOps {
28 add(a: number, b: number): number;
29 subtract(a: number, b: number): number;
30}
31
32const 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
38interface Repository {
39 findById(id: string): User | null;
40}
41
42const repo: Repository = {
43 findById: (id) => {
44 // id inferred as string, return type must be User | null
45 return null;
46 },
47};

best practice

Contextual typing is why you rarely need to annotate callback parameters. Let TypeScript infer them from the expected type — it's cleaner and automatically stays in sync.
Literal Inference

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.

literal_inference.ts
TypeScript
1// let → widened to base type
2let direction = "north";
3// inferred: string (not "north")
4direction = "south"; // OK — string allows any string
5
6// const → literal type preserved
7const direction2 = "north";
8// inferred: "north" (not string)
9// direction2 = "south"; // Error: Cannot assign to 'direction2'
10
11// const with object → properties still widened
12const config = { host: "localhost", port: 3000 };
13// inferred: { host: string; port: number }
14config.host = "production"; // OK — host is string
15
16// as const → deeply readonly with literal types
17const 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
22const coords = [1, 2, 3] as const;
23// inferred: readonly [1, 2, 3]
24
25// Useful for: route parameters, status codes, API endpoints
26const API_ROUTES = {
27 users: "/api/users",
28 posts: "/api/posts",
29} as const;
30// typeof API_ROUTES["users"] → "/api/users" (literal, not string)
const Assertions

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.

const_assertions.ts
TypeScript
1// Without as const — widened types
2let colors = ["red", "green", "blue"];
3// inferred: string[]
4colors.push("yellow"); // OK
5
6// With as const — literal readonly types
7let 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
12const HttpStatus = {
13 OK: 200,
14 NotFound: 404,
15 ServerError: 500,
16} as const;
17
18type Status = (typeof HttpStatus)[keyof typeof HttpStatus];
19// type Status = 200 | 404 | 500
20
21function 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
33const DIRECTIONS = ["north", "south", "east", "west"] as const;
34type Direction = (typeof DIRECTIONS)[number];
35// type Direction = "north" | "south" | "east" | "west"
36
37// Function that only accepts specific values
38function move(direction: Direction): void {
39 console.log(`Moving ${direction}`);
40}
41
42move("north"); // OK
43// move("up"); // Error: "up" is not assignable to Direction

info

as constis the TypeScript equivalent of defining a const enum without the enum syntax. It's the preferred way to create literal-typed constants.
Type Widening

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.

widening.ts
TypeScript
1// Default widening
2let x = "hello"; // widened from "hello" to string
3x = "world"; // OK — string allows any string
4
5// Widening in conditionals
6function 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)
15type NoWidening<T> = T extends string ? string : T;
16
17// Widening with default values
18function greet(name: string = "world") {
19 // name: string — default value doesn't narrow
20}
21
22// Widening with union types
23type Color = "red" | "green" | "blue";
24
25let c: Color = "red"; // OK — "red" is assignable to Color
26let d = "red"; // widened to string — not Color!
27
28// To keep literal type, use:
29let e: Color = "red" as Color; // explicit annotation
30let f = "red" as const; // literal type "red"
Type Narrowing

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

narrowing_typeof.ts
TypeScript
1function 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

narrowing_instanceof.ts
TypeScript
1function 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

narrowing_discriminated.ts
TypeScript
1type Shape =
2 | { kind: "circle"; radius: number }
3 | { kind: "rectangle"; width: number; height: number }
4 | { kind: "triangle"; base: number; height: number };
5
6function 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
18class Dog {
19 bark() { return "Woof!"; }
20}
21
22class Cat {
23 meow() { return "Meow!"; }
24}
25
26function 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

narrowing_predicates.ts
TypeScript
1// Custom type guard — "is" keyword
2function isString(value: unknown): value is string {
3 return typeof value === "string";
4}
5
6function 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
14function isDog(animal: Dog | Cat): animal is Dog {
15 return animal instanceof Dog;
16}
17
18// Filter with type predicate
19let mixed: (string | number)[] = [1, "hello", 2, "world"];
20let strings = mixed.filter(isString); // inferred: string[]
21
22// Assertion function (throws if wrong)
23function assertIsString(value: unknown): asserts value is string {
24 if (typeof value !== "string") {
25 throw new Error("Expected string");
26 }
27}
28
29function example(value: unknown) {
30 assertIsString(value);
31 // TS knows: value is string (after assertion)
32 console.log(value.toUpperCase());
33}

best practice

Discriminated unions are the most TypeScript-idiomatic way to handle variant data. Add a kind or type field to each variant, and TypeScript will narrow automatically in switch statements.
$Blueprint — Engineering Documentation·Section ID: TS-INFER·Revision: 1.0