Type Inference
Type Inference
One of TypeScript's greatest strengths is its ability to infer types. Understanding when to let TypeScript infer types and when to provide explicit annotations is crucial for writing clean, effective code.
Item 19: Avoid Cluttering Your Code with Inferable Types
TypeScript can infer types in most situations. Don't annotate unnecessarily:
// Unnecessary annotations (cluttered)
const person: { name: string; age: number } = {
name: "Alice",
age: 30,
};
const numbers: number[] = [1, 2, 3, 4, 5];
const doubled: number[] = numbers.map((n: number): number => n * 2);
// Let TypeScript infer (clean)
const person = {
name: "Alice",
age: 30,
};
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map((n) => n * 2);Note
Explicit type annotations should add information that TypeScript can't infer. If TypeScript can figure it out, let it.
When Inference Works Well
// Variable initialization
const name = "Alice"; // string
const count = 42; // number
const isActive = true; // boolean
const items = ["a", "b", "c"]; // string[]
// Function return types (often)
function add(a: number, b: number) {
return a + b; // Return type inferred as number
}
// Array methods
const users = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
];
const names = users.map((u) => u.name); // string[]
const adults = users.filter((u) => u.age >= 18); // same type as usersWhen to Add Explicit Types
Cases Where Explicit Types Help
// 1. Function parameters (required)
function greet(name: string) {
return `Hello, ${name}!`;
}
// 2. Empty arrays (TypeScript infers never[])
const items: string[] = []; // Without annotation: never[]
items.push('hello');
// 3. Object literals for API contracts
interface User {
id: string;
name: string;
email: string;
}
// Explicit type catches missing properties immediately
const user: User = {
id: '1',
name: 'Alice'
// Error: Property 'email' is missing
};
// 4. When the inferred type is too narrow or too wide
const ROUTES = {
home: '/',
about: '/about',
contact: '/contact'
} as const; // Without 'as const', values are just string
// 5. Public function return types (for documentation)
export function fetchUser(id: string): Promise<User> {
// Return type documents the API contract
return fetch(`/api/users/${id}`).then(r => r.json());
}
Item 20: Use Different Variables for Different Types
Don't reuse variables for different types:
// BAD: Reusing variable for different types
let id = '12-34-56';
// ... later
id = 123456; // Error in TypeScript, confusion in JavaScript
// BAD: Union type when you mean different things
let productId: string | number = '12-34-56';
productId = 123456; // Allowed but confusing
// GOOD: Use different variables
const stringId = '12-34-56';
const numericId = 123456;Warning
Reusing variables with different types leads to confusion and bugs. TypeScript's type narrowing works best when variables have consistent types.
The Exception: Narrowing
It's fine for a variable's type to narrow during its lifetime:
function processInput(input: string | number) {
// input starts as string | number
if (typeof input === "string") {
// input is narrowed to string
return input.toUpperCase();
}
// input is narrowed to number
return input.toFixed(2);
}Item 21: Understand Type Widening
When you declare a variable with let and initialize it with a literal, TypeScript widens the type:
// Type widening with let
let x = "hello"; // Type: string (not 'hello')
x = "world"; // OK
// No widening with const
const y = "hello"; // Type: 'hello' (literal type)
// y = 'world'; // Error!How Widening Works
// Primitives widen
let a = 42; // number
let b = true; // boolean
let c = "hello"; // string
// Arrays widen to mutable arrays
let arr = [1, 2, 3]; // number[]
arr.push(4); // OK
// Objects have their properties widened
let point = { x: 3, y: 4 }; // { x: number; y: number }
point.x = 10; // OKControlling Widening
Preventing Unwanted Widening
// 1. Use const for primitives
const status = 'loading'; // Type: 'loading'
// 2. Use 'as const' for objects and arrays
const point = { x: 3, y: 4 } as const;
// Type: { readonly x: 3; readonly y: 4 }
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ['red', 'green', 'blue']
// 3. Explicit type annotation
let direction: 'left' | 'right' = 'left';
direction = 'right'; // OK
direction = 'up'; // Error!
// 4. Use satisfies for inference with constraints
const palette = {
primary: '#007bff',
secondary: '#6c757d'
} satisfies Record<string, string>;
// Type is preserved as { primary: string; secondary: string }
// But TypeScript checks it matches Record<string, string>
Item 22: Understand Type Narrowing
Type narrowing is how TypeScript refines types based on control flow:
function processValue(value: string | number | null) {
// value is string | number | null
if (value === null) {
return 'nothing';
// value is null here
}
// value is string | number (null eliminated)
if (typeof value === 'string') {
return value.toUpperCase();
// value is string
}
return value.toFixed(2);
// value is number
}Narrowing Techniques
// 1. typeof guards
function format(value: string | number) {
if (typeof value === "string") {
return value.trim();
}
return value.toLocaleString();
}
// 2. Truthiness narrowing
function printName(name: string | null | undefined) {
if (name) {
console.log(name.toUpperCase()); // name is string
}
}
// 3. Equality narrowing
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y must both be string
console.log(x.toUpperCase());
}
}
// 4. 'in' operator narrowing
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
function move(animal: Bird | Fish) {
if ("fly" in animal) {
animal.fly(); // animal is Bird
} else {
animal.swim(); // animal is Fish
}
}
// 5. instanceof narrowing
function logDate(date: Date | string) {
if (date instanceof Date) {
console.log(date.toISOString());
} else {
console.log(new Date(date).toISOString());
}
}Discriminated Unions
The most powerful narrowing pattern is the discriminated union:
Discriminated Unions
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: string[];
}
interface ErrorState {
status: 'error';
error: Error;
}
type RequestState = LoadingState | SuccessState | ErrorState;
function handleRequest(state: RequestState) {
switch (state.status) {
case 'loading':
return 'Loading...';
case 'success':
return state.data.join(', '); // data is accessible
case 'error':
return state.error.message; // error is accessible
}
}
// Exhaustiveness checking
function assertNever(x: never): never {
throw new Error('Unexpected value: ' + x);
}
function handleRequestExhaustive(state: RequestState) {
switch (state.status) {
case 'loading':
return 'Loading...';
case 'success':
return state.data.join(', ');
case 'error':
return state.error.message;
default:
return assertNever(state); // Compile error if we miss a case
}
}
Item 23: Create Objects All at Once
TypeScript infers types when objects are created. Building objects piece by piece can cause problems:
// BAD: Building objects incrementally
const point = {};
point.x = 3; // Error! Property 'x' does not exist on type '{}'
point.y = 4; // Error!
// GOOD: Create all at once
const point = {
x: 3,
y: 4
};
// If you need to build incrementally, declare the type first
interface Point {
x: number;
y: number;
}
const point: Point = {} as Point; // Use assertion sparingly
point.x = 3;
point.y = 4;Object Spread for Conditional Properties
// Use spread to conditionally add properties
interface Config {
name: string;
debug?: boolean;
logLevel?: "info" | "warn" | "error";
}
function createConfig(name: string, isDev: boolean): Config {
return {
name,
...(isDev && {
debug: true,
logLevel: "info",
}),
};
}Note
Object spread (...) is your friend for creating objects with conditional
properties while maintaining type safety.
Item 24: Be Consistent in Your Use of Aliases
Type aliases can cause confusion if the aliased value changes:
interface Point {
x: number;
y: number;
}
const point: Point = { x: 3, y: 4 };
// Creating an alias
const { x, y } = point;
// Now point and x/y are different!
point.x = 10;
console.log(x); // Still 3!Aliases in Narrowing
function processPoint(point: Point | null) {
// BAD: Using alias after narrowing the original
const pt = point;
if (point !== null) {
console.log(pt.x); // Error! pt might be null
}
// GOOD: Use the same reference
if (point !== null) {
console.log(point.x); // OK
}
}Warning
Be careful with aliases in control flow. TypeScript tracks narrowing on the exact reference you check, not on aliases.
Item 25: Use async Functions Instead of Callbacks for Async Code
TypeScript works much better with async/await than with callbacks:
// BAD: Callback style (hard to type correctly)
function fetchDataCallback(
url: string,
onSuccess: (data: unknown) => void,
onError: (error: Error) => void,
) {
fetch(url)
.then((r) => r.json())
.then(onSuccess)
.catch(onError);
}
// GOOD: async/await (TypeScript infers types beautifully)
async function fetchData(url: string): Promise<unknown> {
const response = await fetch(url);
return response.json();
}Async Functions Simplify Types
// With callbacks, type inference is lost
function processUsers(callback: (users: User[]) => void) {
fetchDataCallback('/api/users',
(data) => callback(data as User[]), // Cast needed
(error) => console.error(error)
);
}
// With async, types flow naturally
async function getUsers(): Promise<User[]> {
const response = await fetch('/api/users');
const data = await response.json();
return data; // TypeScript can check this matches Promise<User[]>
}
// Parallel operations with Promise.all
async function getUserWithPosts(userId: string) {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId)
]);
// TypeScript infers: [User, Post[]]
return { user, posts };
}
Item 26: Understand How Context Is Used in Type Inference
TypeScript uses context to infer types. Separating values from their usage context can break inference:
type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) {
console.log(`Language set to ${language}`);
}
// Context preserved - works
setLanguage('TypeScript');
// Context lost - may fail
let language = 'TypeScript'; // Type: string (too wide!)
setLanguage(language); // Error!
// Solutions:
// 1. Use const
const language = 'TypeScript'; // Type: 'TypeScript'
setLanguage(language); // OK
// 2. Add type annotation
let language: Language = 'TypeScript';
setLanguage(language); // OK
// 3. Use 'as const'
let language = 'TypeScript' as const;
setLanguage(language); // OKContext in Callbacks
// Context flows into callbacks
const languages: Language[] = ["JavaScript", "TypeScript"];
languages.forEach((lang) => {
// lang is inferred as Language
setLanguage(lang); // OK
});
// But can be lost with intermediate variables
const setLang = (lang: string) => setLanguage(lang); // Error!
// Need explicit type:
const setLang = (lang: Language) => setLanguage(lang);Context with Object Literals
interface Config {
theme: "light" | "dark";
language: Language;
}
// Inline - context preserved
function configure(config: Config) {
/* ... */
}
configure({
theme: "dark",
language: "TypeScript", // Inferred as literal
});
// Separate variable - context can be lost
const config = {
theme: "dark",
language: "TypeScript",
};
// theme is string, language is string - too wide!
// Fix with satisfies
const config = {
theme: "dark",
language: "TypeScript",
} satisfies Config;
// Or explicit type
const config: Config = {
theme: "dark",
language: "TypeScript",
};Item 27: Use Functional Constructs and Libraries to Help Types Flow
Functional programming constructs help TypeScript infer types:
// Imperative style - requires explicit types
const evenNumbers: number[] = [];
for (let i = 0; i < 10; i++) {
if (i % 2 === 0) {
evenNumbers.push(i);
}
}
// Functional style - types flow naturally
const evenNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].filter((n) => n % 2 === 0);Type Flow with Array Methods
interface User {
id: string;
name: string;
email: string;
active: boolean;
}
const users: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', active: true },
{ id: '2', name: 'Bob', email: 'bob@example.com', active: false },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', active: true }
];
// Types flow through the entire chain
const activeUserEmails = users
.filter(u => u.active) // User[]
.map(u => u.email) // string[]
.map(e => e.toLowerCase()); // string[]
// Build lookup maps with reduce
const userById = users.reduce(
(acc, user) => ({ ...acc, [user.id]: user }),
{} as Record<string, User>
);
// Using Object.fromEntries
const userById2 = Object.fromEntries(
users.map(user => [user.id, user])
);
// Type: { [k: string]: User }
Note
Libraries like Lodash have excellent TypeScript definitions that make types flow even better. Consider using them for complex transformations.
Key Takeaways
- Don't over-annotate - let TypeScript infer types when possible
- Do annotate function parameters and public function return types
- Use different variables for different types, don't reuse
- Understand widening - use
as const, explicit types, orsatisfiesto control it - Master narrowing - typeof, in, instanceof, and discriminated unions
- Create objects all at once or use type assertions carefully
- Be consistent with aliases to avoid narrowing confusion
- Prefer async/await for better type inference
- Keep values in context with their usage for better inference
- Use functional constructs to help types flow through transformations
In the next chapter, we'll explore type design - how to create types that are precise, helpful, and prevent bugs.