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:
typeofchecks (for primitives)instanceofchecks (for classes)inoperator (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)+readonlyorreadonly: 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 Type | Purpose | Example |
|---|---|---|
Partial<T> | All properties optional | Partial<User> |
Required<T> | All properties required | Required<Config> |
Readonly<T> | All properties readonly | Readonly<State> |
Pick<T, K> | Select specific properties | Pick<User, "id" | "name"> |
Omit<T, K> | Exclude specific properties | Omit<User, "password"> |
Record<K, T> | Object with keys K and values T | Record<string, number> |
Exclude<T, U> | Remove types from union | Exclude<"a"|"b"|"c", "a"> |
Extract<T, U> | Extract types from union | Extract<string|number, number> |
NonNullable<T> | Remove null and undefined | NonNullable<string | null> |
ReturnType<T> | Extract function return type | ReturnType<typeof fn> |
Parameters<T> | Extract function parameter types | Parameters<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 ๐ฏ
Union types (
A | B) represent "one or the other", while intersection types (A & B) represent "must have both"Type guards narrow union types using runtime checks (
typeof,instanceof,in, custom predicates)Conditional types (
T extends U ? X : Y) enable type-level logic and computationMapped types (
[P in keyof T]) transform existing types by iterating over propertiesTemplate literal types create string types from combinations of string literals
Utility types like
Partial,Pick,Omit, andRecordsolve common transformation needsUse discriminated unions with a common property for type-safe state management
The
inferkeyword extracts types from within conditional type expressionsType narrowing happens automatically based on control flow analysis
Exhaustive checking with
neverensures 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
- TypeScript Handbook - Advanced Types: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
- Type Challenges Repository: https://github.com/type-challenges/type-challenges
- TypeScript Deep Dive: https://basarat.gitbook.io/typescript/type-system
๐ Quick Reference Card
| Union Types | A | B - value can be A or B |
| Intersection Types | A & B - value must be both A and B |
| Type Guards | typeof, instanceof, in, custom predicates |
| Conditional Types | T 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 Keyword | Extract types in conditionals |
| Utility Types | Partial, Pick, Omit, Record, etc. |
| Discriminated Unions | Common property distinguishes union members |
| never Type | Represents impossible values, used for exhaustive checks |