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

Type System Mastery

Deep dive into TypeScript's type system, including primitive types, object types, type inference, and advanced type combinations.

Type System Mastery

Master TypeScript's type system with free flashcards and spaced repetition to reinforce your learning. This lesson covers advanced types, type guards, conditional types, mapped types, and utility typesโ€”essential concepts for building robust, type-safe applications. Understanding TypeScript's type system deeply will help you catch bugs at compile time and write more maintainable code.

Welcome to Type System Mastery ๐Ÿ’ป

TypeScript's type system is one of the most powerful features of the language, going far beyond simple type annotations. Type system mastery means understanding how to leverage advanced type features to create flexible, reusable, and safe code. Whether you're building complex libraries or large-scale applications, these concepts will transform how you think about types.

In this lesson, you'll explore the sophisticated mechanisms TypeScript provides for modeling complex data relationships, creating type-safe APIs, and ensuring correctness at compile time rather than runtime.

Core Concepts ๐Ÿง 

1. Union and Intersection Types

Union types (A | B) represent values that can be one of several types, while intersection types (A & B) combine multiple types into one.

// Union type - value can be string OR number
type StringOrNumber = string | number;

let value: StringOrNumber;
value = "hello";  // โœ“ valid
value = 42;       // โœ“ valid
value = true;     // โœ— error

// Intersection type - value must satisfy ALL types
type Person = { name: string };
type Employee = { employeeId: number };
type Staff = Person & Employee;

const worker: Staff = {
  name: "Alice",
  employeeId: 123
}; // Must have both properties

๐Ÿ’ก Tip: Union types are like "OR" in logic, intersection types are like "AND".

๐Ÿง  Mnemonic: Union = Unified options (pick one), Intersection = Integrate all (must have both).

2. Type Guards and Narrowing

Type guards are runtime checks that narrow types within conditional blocks. TypeScript automatically narrows types based on control flow analysis.

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

// Custom type guard
interface Fish { swim: () => void; }
interface Bird { fly: () => void; }

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function move(pet: Fish | Bird) {
  if (isFish(pet)) {
    pet.swim();  // TypeScript knows it's Fish
  } else {
    pet.fly();   // TypeScript knows it's Bird
  }
}

Type narrowing occurs automatically with:

  • typeof checks (for primitives)
  • instanceof checks (for classes)
  • in operator (for property existence)
  • Custom type predicates (value is Type)
  • Truthiness checks
  • Equality checks

3. Conditional Types

Conditional types select types based on conditions, using the syntax T extends U ? X : Y.

// Basic conditional type
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // type A = true
type B = IsString<number>;  // type B = false

// Practical example: extract return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getName(): string { return "Alice"; }
type NameType = ReturnType<typeof getName>;  // string

The infer keyword extracts types from within conditional types:

// Extract array element type
type ElementType<T> = T extends (infer U)[] ? U : never;

type Numbers = ElementType<number[]>;  // number
type Strings = ElementType<string[]>;  // string

๐Ÿค” Did you know? Conditional types distribute over union types automatically. T extends U ? X : Y where T = A | B becomes (A extends U ? X : Y) | (B extends U ? X : Y).

4. Mapped Types

Mapped types transform existing types by iterating over their properties.

// Make all properties optional
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// Make all properties readonly
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

// Example usage
interface User {
  id: number;
  name: string;
  email: string;
}

type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; }

type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string; }

Mapping modifiers:

  • +? or ?: Add optional
  • -?: Remove optional (make required)
  • +readonly or readonly: Add readonly
  • -readonly: Remove readonly
// Make all properties required
type Required<T> = {
  [P in keyof T]-?: T[P];
};

// Remove readonly from all properties
type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

5. Template Literal Types

Template literal types build new string types using template literal syntax.

type Direction = "left" | "right" | "up" | "down";
type PaddingDirection = `padding-${Direction}`;
// "padding-left" | "padding-right" | "padding-up" | "padding-down"

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

// Practical example: type-safe CSS properties
type CSSProperty = "color" | "background" | "border";
type CSSValue = string;
type CSSRule = `${CSSProperty}: ${CSSValue}`;

const rule: CSSRule = "color: red";  // โœ“ valid

6. Utility Types

TypeScript provides built-in utility types for common type transformations:

Utility TypePurposeExample
Partial<T>All properties optionalPartial<User>
Required<T>All properties requiredRequired<Config>
Readonly<T>All properties readonlyReadonly<State>
Pick<T, K>Select specific propertiesPick<User, "id" | "name">
Omit<T, K>Exclude specific propertiesOmit<User, "password">
Record<K, T>Object with keys K and values TRecord<string, number>
Exclude<T, U>Remove types from unionExclude<"a"|"b"|"c", "a">
Extract<T, U>Extract types from unionExtract<string|number, number>
NonNullable<T>Remove null and undefinedNonNullable<string | null>
ReturnType<T>Extract function return typeReturnType<typeof fn>
Parameters<T>Extract function parameter typesParameters<typeof fn>

Detailed Examples with Explanations ๐Ÿ”

Example 1: Building a Type-Safe Event System

// Define event map with specific payload types
interface EventMap {
  click: { x: number; y: number };
  keypress: { key: string; code: number };
  submit: { formData: Record<string, any> };
}

// Generic event listener with conditional types
class EventEmitter<T extends Record<string, any>> {
  private listeners: {
    [K in keyof T]?: Array<(payload: T[K]) => void>;
  } = {};

  // Type-safe event registration
  on<K extends keyof T>(event: K, callback: (payload: T[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  // Type-safe event emission
  emit<K extends keyof T>(event: K, payload: T[K]) {
    this.listeners[event]?.forEach(callback => callback(payload));
  }
}

const emitter = new EventEmitter<EventMap>();

// TypeScript enforces correct payload types
emitter.on("click", (payload) => {
  console.log(payload.x, payload.y);  // payload is { x: number; y: number }
});

emitter.emit("click", { x: 10, y: 20 });  // โœ“ valid
emitter.emit("click", { invalid: true });  // โœ— error

Explanation: This example demonstrates mapped types ([K in keyof T]), generic constraints (K extends keyof T), and indexed access types (T[K]). The type system ensures you can only register listeners for valid events and that payloads match the expected structure.

Example 2: Creating a Deep Readonly Type

// Recursive conditional type for deep immutability
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object
    ? T[P] extends Function
      ? T[P]
      : DeepReadonly<T[P]>
    : T[P];
};

interface MutableState {
  user: {
    name: string;
    address: {
      street: string;
      city: string;
    };
  };
  settings: {
    theme: string;
    notifications: boolean;
  };
}

type ImmutableState = DeepReadonly<MutableState>;

const state: ImmutableState = {
  user: {
    name: "Alice",
    address: {
      street: "123 Main St",
      city: "Boston"
    }
  },
  settings: {
    theme: "dark",
    notifications: true
  }
};

// All levels are readonly
state.user.name = "Bob";  // โœ— error
state.user.address.city = "NYC";  // โœ— error
state.settings.theme = "light";  // โœ— error

Explanation: This recursive conditional type applies readonly at every level. It checks if a property is an object (but not a function) and recursively applies DeepReadonly. This is more powerful than TypeScript's built-in Readonly<T>, which only affects top-level properties.

Example 3: Type-Safe API Client with Response Types

// Define API endpoints and their response types
interface ApiEndpoints {
  "/users": { id: number; name: string; email: string }[];
  "/posts": { id: number; title: string; content: string }[];
  "/user/:id": { id: number; name: string; email: string };
}

// Extract path parameters from URL strings
type ExtractParams<T extends string> = 
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
    : T extends `${infer _Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

// Type-safe fetch function
async function fetchApi<T extends keyof ApiEndpoints>(
  endpoint: T,
  ...params: ExtractParams<T> extends Record<string, never>
    ? []
    : [ExtractParams<T>]
): Promise<ApiEndpoints[T]> {
  const url = params[0]
    ? Object.entries(params[0]).reduce(
        (acc, [key, value]) => acc.replace(`:${key}`, value as string),
        endpoint as string
      )
    : endpoint;
  
  const response = await fetch(url);
  return response.json();
}

// Usage with type inference
const users = await fetchApi("/users");  // Type: User[]
const user = await fetchApi("/user/:id", { id: "123" });  // Type: User

Explanation: This example combines template literal types, conditional types, and indexed access types to create a type-safe API client. The ExtractParams type extracts parameter names from URL patterns, and TypeScript enforces that you provide the correct parameters.

Example 4: Discriminated Unions and Exhaustive Checks

// Action types with discriminated unions
type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }
  | { type: "setValue"; value: number };

// Reducer with exhaustive checking
function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return state + action.amount;  // TypeScript knows action has amount
    case "decrement":
      return state - action.amount;  // TypeScript knows action has amount
    case "reset":
      return 0;  // TypeScript knows action has no additional properties
    case "setValue":
      return action.value;  // TypeScript knows action has value
    default:
      // Exhaustiveness check - ensures all cases are covered
      const _exhaustive: never = action;
      return _exhaustive;
  }
}

// Helper type for exhaustive checking
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

Explanation: Discriminated unions use a common property (the type field) to distinguish between union members. TypeScript automatically narrows the type in each case block. The never type in the default case ensures exhaustive checkingโ€”if you add a new action type but forget to handle it, TypeScript will show an error.

Common Mistakes โš ๏ธ

Mistake 1: Confusing Union and Intersection Types

// โŒ WRONG: Expecting intersection behavior from union
type A = { x: number };
type B = { y: number };
type C = A | B;  // Union, not intersection!

const obj: C = { x: 1 };  // โœ“ valid (satisfies A)
const obj2: C = { y: 2 };  // โœ“ valid (satisfies B)
const obj3: C = { x: 1, y: 2 };  // โœ“ also valid (satisfies both)

// โœ… RIGHT: Use intersection for "must have both"
type D = A & B;  // Intersection
const obj4: D = { x: 1, y: 2 };  // Must have both properties

Fix: Remember that | means "one or the other" (or both), while & means "must satisfy all".

Mistake 2: Not Narrowing Types Before Access

// โŒ WRONG: Accessing property without type guard
function processValue(value: string | number) {
  console.log(value.toUpperCase());  // Error: toUpperCase doesn't exist on number
}

// โœ… RIGHT: Use type guard first
function processValue(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());  // โœ“ TypeScript knows it's string
  } else {
    console.log(value.toFixed(2));  // โœ“ TypeScript knows it's number
  }
}

Fix: Always narrow union types using type guards before accessing type-specific properties or methods.

Mistake 3: Incorrect Conditional Type Logic

// โŒ WRONG: Reversed conditional logic
type NonArray<T> = T extends any[] ? T : never;  // Returns arrays, not non-arrays!

// โœ… RIGHT: Correct conditional logic
type NonArray<T> = T extends any[] ? never : T;  // Excludes arrays

type A = NonArray<string>;    // string
type B = NonArray<number[]>;  // never

Fix: Read conditional types as "if T extends U, then X, else Y". Make sure your logic matches your intention.

Mistake 4: Forgetting infer in Conditional Types

// โŒ WRONG: Trying to extract type without infer
type GetReturnType<T> = T extends () => any ? any : never;  // Loses type information

// โœ… RIGHT: Use infer to capture the type
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getData(): { name: string; age: number } {
  return { name: "Alice", age: 30 };
}

type DataType = GetReturnType<typeof getData>;  // { name: string; age: number }

Fix: Use infer keyword to extract and capture types within conditional type expressions.

Mistake 5: Modifying Readonly Properties

// โŒ WRONG: Attempting to modify readonly properties
interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000
};

config.timeout = 10000;  // Error: Cannot assign to 'timeout' because it is a read-only property

// โœ… RIGHT: Create a new object instead
const newConfig: Config = {
  ...config,
  timeout: 10000
};

Fix: Readonly properties cannot be modified. Create new objects with updated values instead.

Key Takeaways ๐ŸŽฏ

  1. Union types (A | B) represent "one or the other", while intersection types (A & B) represent "must have both"

  2. Type guards narrow union types using runtime checks (typeof, instanceof, in, custom predicates)

  3. Conditional types (T extends U ? X : Y) enable type-level logic and computation

  4. Mapped types ([P in keyof T]) transform existing types by iterating over properties

  5. Template literal types create string types from combinations of string literals

  6. Utility types like Partial, Pick, Omit, and Record solve common transformation needs

  7. Use discriminated unions with a common property for type-safe state management

  8. The infer keyword extracts types from within conditional type expressions

  9. Type narrowing happens automatically based on control flow analysis

  10. Exhaustive checking with never ensures you handle all cases in discriminated unions

๐Ÿ’ก Pro Tip: Master these advanced type features to catch more bugs at compile time and build more maintainable TypeScript applications. The type system is your friendโ€”leverage it fully!

๐Ÿ“š Further Study

  1. TypeScript Handbook - Advanced Types: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
  2. Type Challenges Repository: https://github.com/type-challenges/type-challenges
  3. TypeScript Deep Dive: https://basarat.gitbook.io/typescript/type-system

๐Ÿ“‹ Quick Reference Card

Union TypesA | B - value can be A or B
Intersection TypesA & B - value must be both A and B
Type Guardstypeof, instanceof, in, custom predicates
Conditional TypesT extends U ? X : Y - type-level if/else
Mapped Types[P in keyof T]: T[P] - transform properties
Template Literals`prefix-${T}` - build string types
infer KeywordExtract types in conditionals
Utility TypesPartial, Pick, Omit, Record, etc.
Discriminated UnionsCommon property distinguishes union members
never TypeRepresents impossible values, used for exhaustive checks

Practice Questions

Test your understanding with these questions:

Q1: Write a generic type that extracts the element type from an array type. For example, given number[], it should return number. ```typescript type ElementType<T> = // your implementation ```
A: !AI
Q2: Create a mapped type that makes all properties of a type optional and readonly simultaneously. ```typescript type PartialReadonly<T> = // your implementation ```
A: !AI