Learning Guides
Menu

Type Inference

12 min readEffective TypeScript

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:

TYPESCRIPT
// 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

TYPESCRIPT
// 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 users

When to Add Explicit Types

Cases Where Explicit Types Help

TYPESCRIPT
// 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:

TYPESCRIPT
// 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:

TYPESCRIPT
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:

TYPESCRIPT
// 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

TYPESCRIPT
// 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; // OK

Controlling Widening

Preventing Unwanted Widening

TYPESCRIPT
// 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:

TYPESCRIPT
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

TYPESCRIPT
// 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

TYPESCRIPT
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:

TYPESCRIPT
// 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

TYPESCRIPT
// 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:

TYPESCRIPT
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

TYPESCRIPT
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:

TYPESCRIPT
// 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

TYPESCRIPT
// 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:

TYPESCRIPT
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);  // OK

Context in Callbacks

TYPESCRIPT
// 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

TYPESCRIPT
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:

TYPESCRIPT
// 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

TYPESCRIPT
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

  1. Don't over-annotate - let TypeScript infer types when possible
  2. Do annotate function parameters and public function return types
  3. Use different variables for different types, don't reuse
  4. Understand widening - use as const, explicit types, or satisfies to control it
  5. Master narrowing - typeof, in, instanceof, and discriminated unions
  6. Create objects all at once or use type assertions carefully
  7. Be consistent with aliases to avoid narrowing confusion
  8. Prefer async/await for better type inference
  9. Keep values in context with their usage for better inference
  10. 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.

PLAINTEXT