You are viewing a preview of this lesson. Sign in to start learning
Back to TypeScript

Type Inference & Combining

Leverage automatic type detection and create flexible types through unions, aliases, and keyof operators.

Type Inference & Combining Types

Master TypeScript's type inference and type combination strategies with free flashcards and spaced repetition practice. This lesson covers how TypeScript automatically infers types, union types, intersection types, and advanced type composition techniques—essential concepts for writing maintainable, type-safe code without excessive annotations.

Welcome to Type Inference & Combining Types 💻

TypeScript's type system shines brightest when you understand how the compiler infers types and how to combine types effectively. Rather than manually annotating every variable, TypeScript intelligently determines types based on context. When single types aren't enough, you can combine them using unions, intersections, and other powerful operators.

This lesson will transform how you think about types—from static annotations to dynamic, composable building blocks that make your code both flexible and safe.

Core Concept 1: Type Inference Fundamentals 🧠

Type inference is TypeScript's ability to automatically determine types without explicit annotations. The compiler analyzes your code's structure, values, and context to deduce the most appropriate types.

How Inference Works

TypeScript infers types at several key moments:

  1. Variable initialization - The type of the initial value becomes the variable's type
  2. Function returns - Based on what the function returns
  3. Contextual typing - Based on where a value is used
  4. Best common type - When multiple types are present
// Variable inference
let message = "Hello";  // inferred as string
let count = 42;          // inferred as number
let isActive = true;     // inferred as boolean

// Array inference
let numbers = [1, 2, 3]; // inferred as number[]
let mixed = [1, "two"];  // inferred as (string | number)[]

💡 Pro Tip: Hover over variables in your IDE to see inferred types—this is invaluable for understanding what TypeScript "sees".

Function Return Type Inference

TypeScript infers return types by analyzing all return statements:

function add(a: number, b: number) {
  return a + b;  // return type inferred as number
}

function getUser(id: number) {
  if (id > 0) {
    return { id, name: "Alice" };  // inferred as { id: number; name: string }
  }
  return null;  // function returns { id: number; name: string } | null
}

⚠️ Important: While return type inference is convenient, explicitly annotating function returns often improves error messages and prevents accidental API changes.

Contextual Typing

TypeScript uses context to infer types when values appear in specific positions:

window.addEventListener("click", (event) => {
  // event is inferred as MouseEvent based on the "click" context
  console.log(event.clientX);
});

const numbers = [1, 2, 3];
numbers.map((num) => {
  // num is inferred as number from the array type
  return num * 2;
});

Best Common Type Algorithm

When TypeScript encounters multiple types in an array or expression, it finds the most specific type that encompasses all values:

let values = [1, 2, null];  // inferred as (number | null)[]

class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
class Cat extends Animal { meow() {} }

let pets = [new Dog(), new Cat()];  // inferred as (Dog | Cat)[]
// Note: TypeScript doesn't infer Animal[] unless explicitly told

Core Concept 2: Union Types 🔀

Union types represent values that can be one of several types. They're created using the pipe operator |.

let id: string | number;
id = "ABC123";  // valid
id = 456;       // valid
id = true;      // Error: boolean not in union

function formatOutput(value: string | number | boolean): string {
  return String(value);
}

Working with Union Types

When you have a union type, you can only access members that exist on all types in the union:

function printId(id: string | number) {
  console.log(id.toString());  // OK - both types have toString()
  console.log(id.toUpperCase());  // Error - number doesn't have toUpperCase()
}

Type Narrowing with Unions

Type narrowing is the process of refining union types to more specific types within conditional blocks:

function processValue(value: string | number) {
  if (typeof value === "string") {
    // TypeScript narrows value to string here
    console.log(value.toUpperCase());
  } else {
    // TypeScript narrows value to number here
    console.log(value.toFixed(2));
  }
}

// Narrowing with truthiness
function printName(name: string | null | undefined) {
  if (name) {
    // name is narrowed to string (truthy)
    console.log(name.toUpperCase());
  } else {
    console.log("No name provided");
  }
}

// Narrowing with instanceof
function handleError(error: Error | string) {
  if (error instanceof Error) {
    console.log(error.message);
  } else {
    console.log(error);
  }
}

Discriminated Unions

A powerful pattern using a common property (discriminant) to distinguish between union members:

interface Circle {
  kind: "circle";  // discriminant property
  radius: number;
}

interface Square {
  kind: "square";  // discriminant property
  sideLength: number;
}

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // TypeScript knows shape is Circle here
      return Math.PI * shape.radius ** 2;
    case "square":
      // TypeScript knows shape is Square here
      return shape.sideLength ** 2;
  }
}

💡 Memory Device: Think of unions as "OR" logic—a value can be Type A OR Type B OR Type C.

Core Concept 3: Intersection Types 🔺

Intersection types combine multiple types into one, requiring a value to satisfy all constituent types. They're created using the ampersand operator &.

interface HasName {
  name: string;
}

interface HasAge {
  age: number;
}

type Person = HasName & HasAge;

const person: Person = {
  name: "Alice",
  age: 30
  // Must have BOTH name and age
};

When to Use Intersections

Intersections are ideal for:

  1. Combining multiple interfaces
  2. Adding properties to existing types
  3. Mixin patterns
// Combining interfaces
interface Clickable {
  click(): void;
}

interface Hoverable {
  hover(): void;
}

type InteractiveElement = Clickable & Hoverable;

const button: InteractiveElement = {
  click() { console.log("Clicked"); },
  hover() { console.log("Hovering"); }
};

// Adding properties to existing types
type WithTimestamp<T> = T & { timestamp: Date };

interface User {
  id: number;
  name: string;
}

type TimestampedUser = WithTimestamp<User>;

const user: TimestampedUser = {
  id: 1,
  name: "Bob",
  timestamp: new Date()
};

Intersections vs. Unions

Aspect Union (A | B) Intersection (A & B)
Logic OR - can be A or B AND - must be both A and B
Properties Only common properties accessible All properties from both types required
Use Case Multiple possible types Combining multiple contracts
Example string | number Named & Aged

Intersection with Conflicting Types

⚠️ Watch out: Intersecting primitive types creates the never type:

type Impossible = string & number;  // never - can't be both!

function test(value: Impossible) {
  // This function can never be called - no value satisfies both string AND number
}

However, intersecting object types merges their properties:

interface A { x: number; }
interface B { y: string; }
type C = A & B;  // { x: number; y: string; }

💡 Pro Tip: Use intersections to build complex types from simple, reusable building blocks—following the composition over inheritance principle.

Core Concept 4: Type Aliases vs Interfaces 📝

Both type and interface can define object shapes, but they have important differences:

// Type alias
type Point = {
  x: number;
  y: number;
};

// Interface
interface Point2D {
  x: number;
  y: number;
}

Key Differences

Feature Type Alias Interface
Extending Uses & (intersection) Uses extends keyword
Declaration Merging ❌ Not supported ✅ Supported
Primitives/Unions ✅ Can represent any type ❌ Only object shapes
Computed Properties ✅ Supported ❌ Limited support
Use Case Complex types, unions, utilities Object contracts, public APIs

Declaration Merging with Interfaces

Interfaces with the same name automatically merge:

interface User {
  name: string;
}

interface User {
  age: number;
}

// Both declarations merge into one:
const user: User = {
  name: "Alice",
  age: 30
};

When to Use Which?

Use type when:

  • Defining unions or intersections
  • Creating utility types with mapped types
  • Aliasing primitive types
  • Using complex conditional types

Use interface when:

  • Defining object shapes for public APIs
  • You need declaration merging
  • Defining class contracts
  • Building extensible type hierarchies
// Types are better for unions
type Status = "pending" | "approved" | "rejected";
type Result = Success | Error;

// Interfaces are better for object contracts
interface Repository<T> {
  getAll(): Promise<T[]>;
  getById(id: string): Promise<T>;
  save(item: T): Promise<void>;
}

Detailed Example 1: Building a Form Validation System 📋

Let's combine type inference, unions, and intersections to create a robust form validation system:

// Base field types
interface BaseField {
  name: string;
  label: string;
  required: boolean;
}

// Specific field types
interface TextField extends BaseField {
  type: "text";
  maxLength?: number;
}

interface NumberField extends BaseField {
  type: "number";
  min?: number;
  max?: number;
}

interface SelectField extends BaseField {
  type: "select";
  options: string[];
}

// Discriminated union of all field types
type FormField = TextField | NumberField | SelectField;

// Validation result types
type ValidationSuccess = {
  valid: true;
  value: string | number;
};

type ValidationError = {
  valid: false;
  errors: string[];
};

type ValidationResult = ValidationSuccess | ValidationError;

// Type guard function
function isValidationError(result: ValidationResult): result is ValidationError {
  return !result.valid;
}

// Validation function using type narrowing
function validateField(field: FormField, value: any): ValidationResult {
  const errors: string[] = [];

  // Check required
  if (field.required && !value) {
    errors.push(`${field.label} is required`);
    return { valid: false, errors };
  }

  // Type-specific validation using discriminant
  switch (field.type) {
    case "text":
      if (field.maxLength && value.length > field.maxLength) {
        errors.push(`${field.label} must be ${field.maxLength} characters or less`);
      }
      break;
    
    case "number":
      const num = Number(value);
      if (isNaN(num)) {
        errors.push(`${field.label} must be a number`);
      }
      if (field.min !== undefined && num < field.min) {
        errors.push(`${field.label} must be at least ${field.min}`);
      }
      if (field.max !== undefined && num > field.max) {
        errors.push(`${field.label} must be at most ${field.max}`);
      }
      break;
    
    case "select":
      if (!field.options.includes(value)) {
        errors.push(`${field.label} must be one of: ${field.options.join(", ")}`);
      }
      break;
  }

  if (errors.length > 0) {
    return { valid: false, errors };
  }

  return { valid: true, value };
}

// Usage
const nameField: TextField = {
  type: "text",
  name: "username",
  label: "Username",
  required: true,
  maxLength: 20
};

const result = validateField(nameField, "john_doe");

if (isValidationError(result)) {
  console.error(result.errors);
} else {
  console.log("Valid value:", result.value);
}

What's happening here:

  1. Discriminated unions (FormField) allow different field configurations
  2. Type narrowing in the switch statement gives us type-specific properties
  3. Union types for ValidationResult handle success/error states elegantly
  4. Type guards (isValidationError) enable safe type narrowing in consuming code
  5. Type inference automatically determines types for variables and returns

Detailed Example 2: API Response Handler with Generics 🌐

Combining intersection types with generics creates powerful, reusable type utilities:

// Base API response structure
interface ApiResponse {
  status: number;
  timestamp: Date;
}

// Success and error response types
interface SuccessResponse<T> extends ApiResponse {
  success: true;
  data: T;
}

interface ErrorResponse extends ApiResponse {
  success: false;
  error: {
    code: string;
    message: string;
  };
}

// Union type for all responses
type Response<T> = SuccessResponse<T> | ErrorResponse;

// Add metadata to any type using intersection
type WithMetadata<T> = T & {
  metadata: {
    requestId: string;
    duration: number;
  };
};

// Combine both patterns
type EnhancedResponse<T> = WithMetadata<Response<T>>;

// Type guard for success responses
function isSuccess<T>(response: Response<T>): response is SuccessResponse<T> {
  return response.success === true;
}

// Generic handler function
function handleResponse<T>(
  response: EnhancedResponse<T>,
  onSuccess: (data: T) => void,
  onError: (error: ErrorResponse["error"]) => void
) {
  console.log(`Request ${response.metadata.requestId} took ${response.metadata.duration}ms`);

  if (isSuccess(response)) {
    // TypeScript knows response.data exists here
    onSuccess(response.data);
  } else {
    // TypeScript knows response.error exists here
    onError(response.error);
  }
}

// Usage with inferred types
interface User {
  id: number;
  name: string;
  email: string;
}

const userResponse: EnhancedResponse<User> = {
  success: true,
  status: 200,
  timestamp: new Date(),
  data: {
    id: 1,
    name: "Alice",
    email: "alice@example.com"
  },
  metadata: {
    requestId: "abc-123",
    duration: 145
  }
};

handleResponse(
  userResponse,
  (user) => {
    // user is inferred as User type
    console.log(`Welcome, ${user.name}!`);
  },
  (error) => {
    console.error(`Error ${error.code}: ${error.message}`);
  }
);

Key techniques demonstrated:

  1. Generic types (Response<T>) make the pattern reusable for any data type
  2. Intersection types (WithMetadata<T>) add properties without modifying original types
  3. Type composition creates EnhancedResponse<T> from smaller building blocks
  4. Type guards enable safe access to discriminated union properties
  5. Type inference in callbacks automatically provides correct parameter types

Detailed Example 3: Event System with Mapped Types 🎯

Let's build a type-safe event system combining multiple type techniques:

// Define event payload types
interface UserEvents {
  login: { userId: string; timestamp: Date };
  logout: { userId: string };
  profileUpdate: { userId: string; fields: string[] };
}

interface SystemEvents {
  error: { code: string; message: string };
  warning: { message: string };
}

// Combine all events using intersection
type AppEvents = UserEvents & SystemEvents;

// Type-safe event handler signature
type EventHandler<T> = (payload: T) => void;

// Mapped type to create handler registry
type EventHandlers = {
  [K in keyof AppEvents]?: EventHandler<AppEvents[K]>[];
};

// Event emitter class
class EventEmitter {
  private handlers: EventHandlers = {};

  // Type-safe registration with constraint
  on<K extends keyof AppEvents>(
    event: K,
    handler: EventHandler<AppEvents[K]>
  ): void {
    if (!this.handlers[event]) {
      this.handlers[event] = [];
    }
    this.handlers[event]!.push(handler);
  }

  // Type-safe emission
  emit<K extends keyof AppEvents>(
    event: K,
    payload: AppEvents[K]
  ): void {
    const eventHandlers = this.handlers[event];
    if (eventHandlers) {
      eventHandlers.forEach(handler => handler(payload));
    }
  }

  // Remove handler
  off<K extends keyof AppEvents>(
    event: K,
    handler: EventHandler<AppEvents[K]>
  ): void {
    const eventHandlers = this.handlers[event];
    if (eventHandlers) {
      const index = eventHandlers.indexOf(handler);
      if (index > -1) {
        eventHandlers.splice(index, 1);
      }
    }
  }
}

// Usage with full type safety
const emitter = new EventEmitter();

// TypeScript knows the exact payload type for each event
emitter.on("login", (payload) => {
  // payload is inferred as { userId: string; timestamp: Date }
  console.log(`User ${payload.userId} logged in at ${payload.timestamp}`);
});

emitter.on("error", (payload) => {
  // payload is inferred as { code: string; message: string }
  console.error(`Error ${payload.code}: ${payload.message}`);
});

// Emit events with type checking
emitter.emit("login", {
  userId: "user123",
  timestamp: new Date()
});

// This would cause a type error:
// emitter.emit("login", { userId: "user123" }); // Missing timestamp!
// emitter.emit("unknown", {}); // Event doesn't exist!

Advanced patterns used:

  1. Intersection of interfaces combines multiple event maps
  2. Mapped types (EventHandlers) transform keys into handler arrays
  3. Generic constraints (K extends keyof AppEvents) ensure only valid event names
  4. Indexed access types (AppEvents[K]) extract specific event payloads
  5. Type inference in callbacks provides exact payload types automatically

Detailed Example 4: State Machine with Literal Types 🔄

Combining literal types, discriminated unions, and type narrowing creates robust state machines:

// State definitions using discriminated unions
type IdleState = {
  status: "idle";
};

type LoadingState = {
  status: "loading";
  progress: number;
};

type SuccessState<T> = {
  status: "success";
  data: T;
  loadedAt: Date;
};

type ErrorState = {
  status: "error";
  error: string;
  retryCount: number;
};

// Union of all possible states
type State<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;

// Valid state transitions
type Transitions<T> = {
  idle: LoadingState;
  loading: SuccessState<T> | ErrorState;
  success: IdleState | LoadingState;
  error: IdleState | LoadingState;
};

// State machine class
class StateMachine<T> {
  private state: State<T>;

  constructor() {
    this.state = { status: "idle" };
  }

  getState(): State<T> {
    return this.state;
  }

  // Type-safe transitions using discriminated unions
  transition(newState: State<T>): void {
    // Validate transition (simplified)
    const currentStatus = this.state.status;
    const newStatus = newState.status;

    console.log(`Transitioning from ${currentStatus} to ${newStatus}`);
    this.state = newState;
  }

  // Helper methods with type narrowing
  isLoading(): this is { state: LoadingState } {
    return this.state.status === "loading";
  }

  isSuccess(): this is { state: SuccessState<T> } {
    return this.state.status === "success";
  }

  isError(): this is { state: ErrorState } {
    return this.state.status === "error";
  }

  // Type-safe data access
  getData(): T | null {
    if (this.state.status === "success") {
      // TypeScript knows this.state.data exists here
      return this.state.data;
    }
    return null;
  }
}

// Usage example
interface ApiData {
  users: string[];
  count: number;
}

const machine = new StateMachine<ApiData>();

// Start loading
machine.transition({ status: "loading", progress: 0 });

// Simulate progress
setTimeout(() => {
  machine.transition({ status: "loading", progress: 50 });
}, 1000);

// Complete successfully
setTimeout(() => {
  machine.transition({
    status: "success",
    data: { users: ["Alice", "Bob"], count: 2 },
    loadedAt: new Date()
  });

  const currentState = machine.getState();
  if (currentState.status === "success") {
    // Type narrowing ensures data access is safe
    console.log(`Loaded ${currentState.data.count} users`);
  }
}, 2000);

// Or handle error
function handleError(retries: number) {
  machine.transition({
    status: "error",
    error: "Network timeout",
    retryCount: retries
  });
}

Design patterns highlighted:

  1. Literal types for status ensure only valid states exist
  2. Discriminated unions enable exhaustive state handling
  3. Generic state machine works with any data type
  4. Type narrowing provides safe access to state-specific properties
  5. Type guards (isLoading(), isSuccess()) simplify conditional logic

Common Mistakes to Avoid ⚠️

Mistake 1: Over-Annotating When Inference Works

Wrong:

const numbers: number[] = [1, 2, 3];  // Unnecessary annotation
const result: number = numbers.reduce((a: number, b: number): number => a + b, 0);

Right:

const numbers = [1, 2, 3];  // Inferred as number[]
const result = numbers.reduce((a, b) => a + b, 0);  // Everything inferred

Mistake 2: Confusing Union and Intersection

Wrong:

// Trying to use intersection when union is needed
function print(value: string & number) {
  // This is 'never' - impossible type!
  console.log(value);
}

Right:

// Use union for "either-or" scenarios
function print(value: string | number) {
  console.log(value.toString());
}

Mistake 3: Not Narrowing Union Types

Wrong:

function getLength(value: string | number) {
  return value.length;  // Error: number doesn't have length
}

Right:

function getLength(value: string | number) {
  if (typeof value === "string") {
    return value.length;
  }
  return value.toString().length;
}

Mistake 4: Missing Discriminant in Unions

Wrong:

interface Cat { meow(): void; }
interface Dog { bark(): void; }
type Pet = Cat | Dog;

function makeSound(pet: Pet) {
  // How do we know which type it is?
  pet.meow();  // Error: might be Dog
}

Right:

interface Cat { type: "cat"; meow(): void; }
interface Dog { type: "dog"; bark(): void; }
type Pet = Cat | Dog;

function makeSound(pet: Pet) {
  if (pet.type === "cat") {
    pet.meow();  // Safe!
  } else {
    pet.bark();  // Safe!
  }
}

Mistake 5: Incorrect Cloze Deletion in Types

Wrong:

// Template that doesn't match when filled
type Result = {{1}} string | Error;  // Answer: "Success<T> |"
// Produces: "Success<T> | string | Error" (extra tokens!)

Right:

type Result = {{1}} | Error;  // Answer: "Success<T> | string"
// Produces: "Success<T> | string | Error" (exact match!)

Mistake 6: Using Any Instead of Unknown for Unions

Wrong:

function process(value: any) {
  // No type safety at all
  return value.toUpperCase();
}

Right:

function process(value: unknown) {
  if (typeof value === "string") {
    return value.toUpperCase();
  }
  return String(value);
}

Key Takeaways 🎯

  1. Type inference reduces boilerplate - Let TypeScript infer types when it's obvious from context, but annotate when it improves clarity or catches errors

  2. Union types represent alternatives - Use A | B when a value can be one type OR another, and always narrow before accessing type-specific properties

  3. Intersection types combine requirements - Use A & B when a value must satisfy both type A AND type B simultaneously

  4. Discriminated unions are powerful - Add a common literal property to union members to enable exhaustive type narrowing

  5. Type narrowing is essential - Use typeof, instanceof, property checks, and type guards to refine union types to specific members

  6. Choose the right tool - Use type for unions, intersections, and utilities; use interface for object contracts and extensible APIs

  7. Composition over complexity - Build complex types from simple, reusable building blocks using unions and intersections

  8. Generic constraints enhance safety - Use extends keyof and indexed access types to create type-safe, flexible APIs

📋 Quick Reference Card

ConceptSyntaxUse Case
Union TypeA | B | CValue can be one of several types
Intersection TypeA & B & CValue must satisfy all types
Type Inferencelet x = 5Automatic type from value
Type Narrowingtypeof x === "string"Refine union to specific type
Type Aliastype Name = TypeName for any type expression
Interfaceinterface Name { }Object contract definition
Discriminant{ kind: "circle" }Property to distinguish unions
Type Guardis TypeFunction that narrows types
Generic ConstraintT extends KeyLimit generic parameter
Indexed AccessT[K]Extract property type

Further Study 📚

  1. TypeScript Handbook - Type Inference: https://www.typescriptlang.org/docs/handbook/type-inference.html
  2. TypeScript Handbook - Unions and Intersections: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types
  3. Advanced TypeScript Patterns: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html

Mastering type inference and type combination unlocks TypeScript's true power—creating flexible, maintainable code with minimal annotations and maximum safety. Practice these patterns in real projects to internalize when to use unions versus intersections, and how to leverage inference for cleaner code! 🚀