Back to Blog
TypeScriptType InferenceTypesBest Practices

TypeScript Type Inference Guide

Understand how TypeScript infers types and when to use explicit annotations.

B
Bootspring Team
Engineering
September 9, 2018
8 min read

TypeScript automatically infers types in many situations. Understanding when and how inference works helps you write cleaner code.

Basic Inference#

1// Variable initialization 2let name = 'John'; // string 3let age = 30; // number 4let active = true; // boolean 5let data = null; // null (not useful) 6let value; // any (avoid!) 7 8// Const has narrower inference 9const PI = 3.14159; // 3.14159 (literal type) 10const greeting = 'hi'; // 'hi' (literal type) 11let greeting2 = 'hi'; // string 12 13// Array inference 14let numbers = [1, 2, 3]; // number[] 15let mixed = [1, 'two', true]; // (number | string | boolean)[] 16let empty = []; // any[] (avoid!) 17 18// Object inference 19let user = { 20 name: 'John', 21 age: 30 22}; 23// { name: string; age: number }

Function Return Inference#

1// Return type inferred from return statement 2function add(a: number, b: number) { 3 return a + b; // Returns number 4} 5 6function greet(name: string) { 7 return `Hello, ${name}`; // Returns string 8} 9 10// Multiple return paths 11function getValue(condition: boolean) { 12 if (condition) { 13 return 42; 14 } 15 return 'default'; 16} 17// Returns: number | string 18 19// Void inferred for no return 20function log(message: string) { 21 console.log(message); 22} 23// Returns: void 24 25// Never for functions that don't return 26function throwError(message: string): never { 27 throw new Error(message); 28} 29 30// Async functions 31async function fetchData() { 32 return { id: 1 }; 33} 34// Returns: Promise<{ id: number }>

Contextual Typing#

1// Callback parameters inferred from context 2const numbers = [1, 2, 3]; 3 4numbers.forEach(n => { 5 // n is inferred as number 6 console.log(n.toFixed(2)); 7}); 8 9numbers.map(n => n * 2); // n is number 10 11// Event handlers 12document.addEventListener('click', event => { 13 // event is MouseEvent 14 console.log(event.clientX); 15}); 16 17// Array methods 18const doubled = numbers.filter(n => n > 1); // number[] 19 20// Object methods 21const entries = Object.entries({ a: 1, b: 2 }); 22// [string, number][] 23 24// Promise handlers 25fetch('/api/data') 26 .then(response => response.json()) // response is Response 27 .then(data => data); // data is any (limitation)

Generic Inference#

1// Type parameter inferred from argument 2function identity<T>(value: T): T { 3 return value; 4} 5 6identity('hello'); // T inferred as string 7identity(42); // T inferred as number 8identity({ x: 1 }); // T inferred as { x: number } 9 10// Multiple type parameters 11function pair<T, U>(first: T, second: U): [T, U] { 12 return [first, second]; 13} 14 15pair('hello', 42); // [string, number] 16 17// Array methods with generics 18const result = [1, 2, 3].map(n => String(n)); 19// string[] (inferred from callback return) 20 21// Inference with constraints 22function getLength<T extends { length: number }>(item: T) { 23 return item.length; 24} 25 26getLength('hello'); // T is string 27getLength([1, 2, 3]); // T is number[]

Object Property Inference#

1// Object literal types 2const config = { 3 host: 'localhost', 4 port: 3000, 5 ssl: false 6}; 7// { host: string; port: number; ssl: boolean } 8 9// Const assertion for literal types 10const config2 = { 11 host: 'localhost', 12 port: 3000, 13 ssl: false 14} as const; 15// { readonly host: 'localhost'; readonly port: 3000; readonly ssl: false } 16 17// Method inference 18const calculator = { 19 value: 0, 20 add(n: number) { 21 this.value += n; 22 return this; 23 } 24}; 25// add returns typeof calculator 26 27// Nested objects 28const nested = { 29 level1: { 30 level2: { 31 value: 42 32 } 33 } 34}; 35// Fully typed nested structure

Destructuring Inference#

1// Array destructuring 2const [first, second] = [1, 2]; 3// first: number, second: number 4 5const [head, ...tail] = [1, 2, 3, 4]; 6// head: number, tail: number[] 7 8// Object destructuring 9const { name, age } = { name: 'John', age: 30 }; 10// name: string, age: number 11 12// Nested destructuring 13const { user: { profile } } = { 14 user: { profile: { bio: 'Hello' } } 15}; 16// profile: { bio: string } 17 18// With defaults 19function greet({ name = 'Guest' }: { name?: string }) { 20 return `Hello, ${name}`; 21} 22// name is string (not string | undefined)

Control Flow Analysis#

1// Type narrowing 2function process(value: string | number) { 3 if (typeof value === 'string') { 4 // value is string here 5 return value.toUpperCase(); 6 } 7 // value is number here 8 return value.toFixed(2); 9} 10 11// Truthiness narrowing 12function processOptional(value?: string) { 13 if (value) { 14 // value is string (not undefined) 15 return value.length; 16 } 17 return 0; 18} 19 20// Discriminated unions 21type Result = 22 | { success: true; data: string } 23 | { success: false; error: Error }; 24 25function handleResult(result: Result) { 26 if (result.success) { 27 // result.data is available 28 console.log(result.data); 29 } else { 30 // result.error is available 31 console.error(result.error); 32 } 33} 34 35// instanceof narrowing 36function processDate(value: Date | string) { 37 if (value instanceof Date) { 38 // value is Date 39 return value.getTime(); 40 } 41 // value is string 42 return Date.parse(value); 43}

When Inference Falls Short#

1// Empty arrays 2const items = []; // any[] 3// Better: const items: string[] = []; 4 5// Object with delayed initialization 6let config; // any 7config = { port: 3000 }; 8// Better: let config: { port: number }; 9 10// Functions without context 11const callback = (x) => x * 2; // Parameter x is any 12// Better: const callback = (x: number) => x * 2; 13 14// JSON.parse returns any 15const data = JSON.parse('{"id": 1}'); // any 16// Better: const data = JSON.parse('...') as { id: number }; 17 18// Complex callbacks 19arr.reduce((acc, item) => { 20 // acc might be any 21 return acc; 22}, {}); // Initial value gives no type info 23// Better: arr.reduce<Record<string, number>>((acc, item) => acc, {});

Explicit vs Inferred#

1// Let TypeScript infer when obvious 2const name = 'John'; // ✓ string is obvious 3const name: string = 'John'; // ✗ Redundant 4 5// Use explicit types for function parameters 6function greet(name: string) { // ✓ Required 7 return `Hello, ${name}`; 8} 9 10// Return types: optional but often helpful 11function add(a: number, b: number): number { // ✓ Documents intent 12 return a + b; 13} 14 15// Public API should be explicit 16interface User { 17 id: number; 18 name: string; 19} 20 21function createUser(name: string): User { // ✓ Clear contract 22 return { id: Date.now(), name }; 23} 24 25// Complex objects benefit from explicit types 26interface Config { 27 host: string; 28 port: number; 29 options?: { 30 timeout: number; 31 retries: number; 32 }; 33} 34 35const config: Config = { 36 host: 'localhost', 37 port: 3000 38};

Inference with Generics#

1// Inference helper 2function createState<T>(initial: T) { 3 let state = initial; 4 return { 5 get: () => state, 6 set: (value: T) => { state = value; } 7 }; 8} 9 10const numState = createState(0); 11numState.set(42); // OK 12// numState.set('hello'); // Error 13 14// Inference with constraints 15function merge<T extends object, U extends object>(a: T, b: U): T & U { 16 return { ...a, ...b }; 17} 18 19const result = merge({ x: 1 }, { y: 2 }); 20// { x: number; y: number } 21 22// Partial inference 23function withDefaults<T extends object>(defaults: T) { 24 return function(overrides: Partial<T>): T { 25 return { ...defaults, ...overrides }; 26 }; 27} 28 29const createConfig = withDefaults({ port: 3000, host: 'localhost' }); 30const config = createConfig({ port: 8080 }); 31// { port: number; host: string }

Satisfies Operator#

1// satisfies validates without widening 2const palette = { 3 red: [255, 0, 0], 4 green: '#00ff00', 5 blue: [0, 0, 255] 6} satisfies Record<string, string | number[]>; 7 8// palette.red is still number[] (not string | number[]) 9palette.red.map(n => n / 255); 10 11// Compare with type annotation 12const palette2: Record<string, string | number[]> = { 13 red: [255, 0, 0] 14}; 15// palette2.red is string | number[] - lost inference!

Best Practices#

1// DO: Let simple types be inferred 2const count = 0; 3const items = ['a', 'b', 'c']; 4const user = { name: 'John', age: 30 }; 5 6// DO: Annotate function parameters 7function processUser(user: User) { } 8 9// DO: Annotate return types for public APIs 10export function createUser(name: string): User { } 11 12// DO: Annotate when inference is wrong 13const ids: number[] = []; // Not any[] 14 15// DO: Use satisfies for validation without widening 16const config = { port: 3000 } satisfies Config; 17 18// DON'T: Annotate when obvious 19const name: string = 'John'; // Redundant 20 21// DON'T: Use any to avoid thinking about types 22const data: any = fetchData(); // Unsafe 23 24// DON'T: Over-annotate callbacks 25numbers.map((n: number): number => n * 2); // Redundant

Summary#

When to let TypeScript infer: ✓ Variable initialization ✓ Function return types (simple cases) ✓ Callback parameters ✓ Generic type arguments ✓ Object literals When to use explicit types: ✓ Function parameters ✓ Public API return types ✓ Empty arrays/objects ✓ Complex generic usage ✓ When inference is wrong Tools for better inference: ✓ const assertions (as const) ✓ satisfies operator ✓ Type guards ✓ Generic constraints

Conclusion#

TypeScript's type inference is powerful and reduces boilerplate. Trust it for simple cases, but use explicit types for function parameters, public APIs, and when inference fails. The satisfies operator and const assertions help when you need validation without losing inference. Good type inference practices lead to cleaner, more maintainable code.

Share this article

Help spread the word about Bootspring