Table of Contents
- Utility Types: Beyond the Basics
- Conditional Types: Type-Level Logic
- Type Guards and Narrowing: Enhancing Type Safety
- Generics with Advanced Constraints
- Mapped Types: Transforming Existing Types
- State Management with TypeScript
- Advanced Function Patterns
- Conclusion
- References
Utility Types: Beyond the Basics
TypeScript’s built-in utility types (e.g., Partial<T>, Readonly<T>) are workhorses for type transformation. But to truly leverage them, you need to understand advanced utilities and how to combine them for complex scenarios.
Recap: Common Utility Types
Before diving in, let’s recap foundational utilities you may already use:
Partial<T>: Makes all properties ofToptional.Readonly<T>: Makes all properties ofTread-only.Pick<T, K>: Selects a subset of propertiesKfromT.Omit<T, K>: Removes propertiesKfromT.
Advanced Utility Types
Beyond the basics, TypeScript provides powerful utilities for type extraction, filtering, and inference:
Exclude<T, U> and Extract<T, U>
Exclude<T, U>: Removes types fromTthat are assignable toU.Extract<T, U>: Keeps types fromTthat are assignable toU(opposite ofExclude).
Example:
type AllTypes = 'a' | 'b' | 'c' | number | boolean;
type Strings = Extract<AllTypes, string>; // 'a' | 'b' | 'c' (keeps string types)
type NonStrings = Exclude<AllTypes, string>; // number | boolean (removes string types)
ReturnType<T> and Parameters<T>
ReturnType<T>: Extracts the return type of a functionT.Parameters<T>: Extracts the parameter types of a functionTas a tuple.
Example:
// Define a function
const fetchUser = () => ({ id: '1', name: 'Alice', age: 30 });
// Extract return type
type User = ReturnType<typeof fetchUser>;
// { id: string; name: string; age: number }
// Extract parameters (if the function had args)
const greet = (name: string, age: number) => `Hello, ${name}`;
type GreetParams = Parameters<typeof greet>; // [string, number]
Combining Utilities for Complex Transformations
The real power of utility types lies in combining them. For example, suppose you want a type that:
- Picks a subset of properties from an interface.
- Makes those properties optional.
- Allows
nullfor each property.
Solution: Combine Pick, Partial, and a custom nullable utility:
type Nullable<T> = { [P in keyof T]: T[P] | null }; // Custom utility
interface User {
id: string;
name: string;
email: string;
age: number;
}
// Step 1: Pick "name" and "email" → { name: string; email: string }
// Step 2: Make them optional → { name?: string; email?: string }
// Step 3: Allow null → { name?: string | null; email?: string | null }
type UserFormData = Nullable<Partial<Pick<User, 'name' | 'email'>>>;
Conditional Types: Type-Level Logic
Conditional types let you write type-level if-else statements, enabling dynamic type transformations based on conditions. Their syntax is:
T extends U ? X : Y
If T is assignable to U, return X; otherwise, return Y.
Basic Conditional Types
Start with simple examples to grasp the syntax:
Example 1: Check if a type is a string
type IsString<T> = T extends string ? 'yes' : 'no';
type A = IsString<string>; // 'yes'
type B = IsString<number>; // 'no'
type C = IsString<string | number>; // 'yes' | 'no' (distributes over unions)
Practical Use Cases
Conditional types shine for extracting nested types, filtering unions, or creating type helpers.
Extracting Nested Property Types
Suppose you want a generic type to extract the type of a nested property (e.g., User['address']['city']).
Solution: Use a conditional type with keyof:
type GetNestedProp<T, K extends string> =
K extends `${infer First}.${infer Rest}`
? First extends keyof T
? GetNestedProp<T[First], Rest>
: never
: K extends keyof T
? T[K]
: never;
// Usage
interface User {
address: {
city: string;
zip: number;
};
preferences: {
notifications: boolean;
};
}
type CityType = GetNestedProp<User, 'address.city'>; // string
type ZipType = GetNestedProp<User, 'address.zip'>; // number
type Invalid = GetNestedProp<User, 'invalid.path'>; // never (invalid path)
Filtering Union Types
Conditional types can filter a union to include only types that meet a condition (e.g., keep only objects in a union).
Example: Extract objects from a union
type ExtractObjects<T> = T extends object ? T : never;
type MixedUnion = string | { id: string } | number | { name: string };
type OnlyObjects = ExtractObjects<MixedUnion>;
// { id: string } | { name: string } (filters out primitives)
Type Guards and Narrowing: Enhancing Type Safety
TypeScript can’t always infer types perfectly (e.g., with unknown or union types). Type guards are functions that help TypeScript narrow down a type, ensuring type safety in runtime checks.
What Are Type Guards?
A type guard is a function that returns a boolean and uses the special return type value is T, telling TypeScript: “If this function returns true, value is of type T.”
User-Defined Type Guards
Create custom guards to validate complex types (e.g., interfaces).
Example: Guard for a User interface
interface User {
id: string;
name: string;
}
// Type guard: Returns true if `value` is a User
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as User).id === 'string' &&
'name' in value &&
typeof (value as User).name === 'string'
);
}
// Usage
function greet(value: unknown) {
if (isUser(value)) {
// Type is narrowed to User here
console.log(`Hello, ${value.name}!`); // ✅ Safe: value is User
} else {
console.log('Unknown value');
}
}
Built-in Narrowing Techniques
TypeScript also supports narrowing with:
typeof(for primitives:string,number, etc.).instanceof(for classes).inoperator (to check if a property exists).
Example: Narrowing with typeof and in
type StringOrNumber = string | number;
function format(value: StringOrNumber) {
if (typeof value === 'string') {
return value.toUpperCase(); // ✅ Narrowed to string
} else {
return value.toFixed(2); // ✅ Narrowed to number
}
}
// Narrowing with `in`
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function makeSound(animal: Dog | Cat) {
if ('bark' in animal) {
animal.bark(); // ✅ Narrowed to Dog
} else {
animal.meow(); // ✅ Narrowed to Cat
}
}
Generics with Advanced Constraints
Generics let you write reusable types/functions, but advanced constraints ensure type safety by restricting what T can be. Beyond basic constraints like T extends object, you can enforce specific shapes, relationships between type parameters, or use keyof for granular control.
Advanced Constraints: Enforce Property Shapes
Instead of T extends object, require T to have specific properties (e.g., an id of type string):
Example: Get an item by ID
// T must have an `id` property of type string
function getById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// Usage (works for any object with { id: string })
interface User { id: string; name: string }
interface Product { id: string; price: number }
const users: User[] = [{ id: '1', name: 'Alice' }];
const products: Product[] = [{ id: 'p1', price: 20 }];
getById(users, '1'); // User | undefined
getById(products, 'p1'); // Product | undefined
Multi-Parameter Constraints
Relate multiple type parameters to enforce consistency. For example, ensure a function’s second argument is a key of the first:
Example: Update an object’s property
function updateProp<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
return { ...obj, [key]: value };
}
// Usage
const user = { name: 'Alice', age: 30 };
updateProp(user, 'name', 'Bob'); // ✅ { name: 'Bob', age: 30 }
updateProp(user, 'age', '30'); // ❌ Error: 'age' expects a number, not string
Using keyof and typeof in Constraints
Combine keyof (get keys of a type) and typeof (infer types from values) for dynamic constraints:
Example: Validate environment variables
// Define allowed env vars and their types
const EnvSchema = {
API_URL: 'string',
MAX_RETRIES: 'number',
} as const;
// Extract keys and value types from the schema
type EnvKeys = keyof typeof EnvSchema;
type EnvValues = { [K in EnvKeys]:
typeof EnvSchema[K] extends 'string' ? string :
typeof EnvSchema[K] extends 'number' ? number :
never
};
// Function to load env vars with type safety
function loadEnv<K extends EnvKeys>(key: K): EnvValues[K] {
const value = process.env[key];
if (EnvSchema[key] === 'number') return Number(value);
return value as EnvValues[K];
}
// Usage
const url = loadEnv('API_URL'); // string
const retries = loadEnv('MAX_RETRIES'); // number
Mapped Types: Transforming Existing Types
Mapped types let you transform each property of an existing type (e.g., make properties optional, readonly, or change their types). They iterate over keyof T and redefine each property.
Basic Mapped Types
TypeScript provides built-in mapped types like Readonly<T> and Partial<T>, but you can create custom ones.
Example: Readonly<T> under the hood
type Readonly<T> = {
readonly [P in keyof T]: T[P]; // Map each key to readonly
};
interface User { name: string; age: number }
type ReadonlyUser = Readonly<User>; // { readonly name: string; readonly age: number }
Advanced Mapped Types
Create mapped types to solve specific problems, like making properties nullable or transforming their types.
Example 1: Nullable<T>
Make all properties of T optional and nullable:
type Nullable<T> = {
[P in keyof T]?: T[P] | null; // Optional and nullable
};
interface User {
name: string;
email: string;
}
type UserForm = Nullable<User>;
// { name?: string | null; email?: string | null }
Example 2: Transform Property Types
Convert all string properties to string | undefined:
type StringPropsToOptional<T> = {
[P in keyof T]: T[P] extends string ? string | undefined : T[P];
};
interface Data {
id: number;
name: string;
description: string;
active: boolean;
}
type DataWithOptionalStrings = StringPropsToOptional<Data>;
// { id: number; name: string | undefined; description: string | undefined; active: boolean }
Example 3: Invert Keys and Values
Swap keys and values of a type (useful for enums or dictionaries):
type Invert<T> = {
[P in keyof T as T[P]]: P; // Use T[P] as the new key, P as the value
};
type Colors = { red: 'R'; green: 'G'; blue: 'B' };
type InvertedColors = Invert<Colors>; // { R: 'red'; G: 'green'; B: 'blue' }
State Management with TypeScript
State management (e.g., React state, Redux) benefits hugely from TypeScript’s type safety. Advanced patterns here ensure actions, reducers, and state are always in sync.
Typing React’s useReducer
useReducer is ideal for complex state logic. Type the state and actions to prevent invalid state transitions.
Example: Counter with useReducer
// Step 1: Define state type
type CounterState = { count: number };
// Step 2: Define action types (union of all possible actions)
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'set'; payload: number };
// Step 3: Type the reducer
const reducer = (state: CounterState, action: CounterAction): CounterState => {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'set': return { count: action.payload };
default: return state;
}
};
// Usage in React
const [state, dispatch] = React.useReducer(reducer, { count: 0 });
dispatch({ type: 'increment' }); // ✅ Valid
dispatch({ type: 'set', payload: 10 }); // ✅ Valid
dispatch({ type: 'reset' }); // ❌ Error: 'reset' is not a valid action
Type-Safe Redux with createSlice
Redux Toolkit’s createSlice auto-generates action types, but TypeScript ensures you never dispatch invalid actions.
Example: Redux Slice with Type Safety
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// State type
interface CounterState { count: number }
// Initial state
const initialState: CounterState = { count: 0 };
// Slice with typed reducers
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => { state.count += 1; },
decrement: (state) => { state.count -= 1; },
set: (state, action: PayloadAction<number>) => {
state.count = action.payload;
},
},
});
// Auto-generated actions (type-safe)
export const { increment, decrement, set } = counterSlice.actions;
// Dispatch in components
dispatch(increment()); // ✅
dispatch(set(5)); // ✅
dispatch({ type: 'counter/increment' }); // ✅ (string literal also works)
dispatch({ type: 'counter/reset' }); // ❌ Error: unknown action
Advanced Function Patterns
TypeScript enables advanced function patterns like overloads, currying, and generic functions with conditional returns, ensuring type safety at every step.
Function Overloads
Function overloads let you define multiple type signatures for a single function, enabling different input/output types.
Example: A function that greets users or guests
// Overload signatures (visible to callers)
function greet(name: string): string; // Greet a user
function greet(): string; // Greet a guest
// Implementation (hidden from callers)
function greet(name?: string): string {
if (name) return `Hello, ${name}!`;
return 'Hello, guest!';
}
// Usage
greet('Alice'); // "Hello, Alice!" (string)
greet(); // "Hello, guest!" (string)
greet(123); // ❌ Error: number is not assignable to string | undefined
Curried Functions with TypeScript
Currying breaks functions into smaller, reusable functions (e.g., add(5)(3) instead of add(5, 3)). TypeScript ensures each curried step has the correct types.
Example: Curried Add Function
// Curried function with type safety
const curryAdd = (a: number) => (b: number): number => a + b;
// Usage
const add5 = curryAdd(5); // (b: number) => number
add5(3); // 8 (number)
add5('3'); // ❌ Error: '3' is not a number
Generic Functions with Conditional Returns
Combine generics and conditional types to return different types based on input.
Example: Get a value or array of values
type GetValue<T> = T extends any[] ? T[number] : T;
function getValue<T>(input: T): GetValue<T> {
return Array.isArray(input) ? input[0] : input;
}
// Usage
const a = getValue('hello'); // string (T is string → GetValue<T> is string)
const b = getValue([1, 2, 3]); // number (T is number[] → GetValue<T> is number)
const c = getValue([{ id: 1 }, { id: 2 }]); // { id: number }
Conclusion
Advanced TypeScript patterns transform how you write type-safe, maintainable code. From utility types and conditional types to mapped types and state management, these tools empower you to:
- Reduce bugs by catching type errors at compile time.
- Improve scalability in large codebases.
- Enhance readability with self-documenting types.
- Refactor with confidence knowing TypeScript will validate changes.
Start small: pick one pattern (e.g., utility types or type guards) and apply it to your project. Over time, these patterns will become second nature, elevating your TypeScript skills to expert level.