TypeScript Advanced Types
/ 10 min read
Introduction
TypeScript’s type system offers powerful features that go beyond basic types. Advanced types allow you to create complex type definitions, ensure type safety, and build robust applications. This guide explores these advanced features and their practical applications.
Union and Intersection Types
Union and intersection types are fundamental building blocks for creating complex type definitions in TypeScript.
1. Union Types
Union types allow a value to be one of several types. They’re particularly useful when a function can handle multiple types of input or when a property can have different types.
// Basic union typetype StringOrNumber = string | number;
// Union with literal typestype Status = "success" | "error" | "pending";type HttpCode = 200 | 404 | 500;
// Function parameter union with type narrowingfunction process(input: string | number) { if (typeof input === "string") { return input.toUpperCase(); } return input.toFixed(2);}
// Practical example: API response handlingtype ApiResponse<T> = { status: Status; data: T | null; error?: string;};
// Usageinterface User { id: number; name: string;}
const response: ApiResponse<User> = { status: "success", data: { id: 1, name: "John" }};2. Intersection Types
Intersection types combine multiple types into one, creating a new type that has all properties of the combined types.
// Basic intersection typeinterface HasName { name: string;}
interface HasAge { age: number;}
type Person = HasName & HasAge;
// Practical example: Role-based permissionsinterface BasicPermissions { read: boolean; write: boolean;}
interface AdminPermissions { delete: boolean; manage: boolean;}
type FullPermissions = BasicPermissions & AdminPermissions;
const adminUser: FullPermissions = { read: true, write: true, delete: true, manage: true};Type Guards and Type Narrowing
Type guards and narrowing help TypeScript understand the type of a value within a certain scope.
1. Type Guards
Type guards are expressions that perform runtime checks to guarantee the type of a value in a certain scope.
// Custom type guardsinterface Car { type: "car"; wheels: number; fuelType: string;}
interface Boat { type: "boat"; propellers: number; waterType: "fresh" | "salt";}
interface Plane { type: "plane"; engines: number; wingspan: number;}
type Vehicle = Car | Boat | Plane;
// Type guard functionsfunction isCar(vehicle: Vehicle): vehicle is Car { return vehicle.type === "car";}
function isBoat(vehicle: Vehicle): vehicle is Boat { return vehicle.type === "boat";}
// Practical usagefunction getVehicleInfo(vehicle: Vehicle): string { if (isCar(vehicle)) { return `Car with ${vehicle.wheels} wheels running on ${vehicle.fuelType}`; } else if (isBoat(vehicle)) { return `Boat with ${vehicle.propellers} propellers for ${vehicle.waterType} water`; } else { return `Plane with ${vehicle.engines} engines and ${vehicle.wingspan}m wingspan`; }}2. Type Narrowing
Type narrowing allows TypeScript to know more specific types based on conditions.
// Discriminated unions with exhaustive checkingtype Result<T> = | { status: "success"; data: T } | { status: "error"; error: string } | { status: "loading" };
function handleResult<T>(result: Result<T>): T | null { switch (result.status) { case "success": return result.data; case "error": console.error(result.error); return null; case "loading": console.log("Loading..."); return null; default: // Exhaustive check: TypeScript will error if we miss any case const _exhaustiveCheck: never = result; return _exhaustiveCheck; }}
// Advanced narrowing with predicatesfunction isNonNullable<T>(value: T): value is NonNullable<T> { return value !== null && value !== undefined;}
// Usageconst values = [1, null, 2, undefined, 3].filter(isNonNullable);// values is number[]Mapped Types
Mapped types allow you to create new types based on existing ones by transforming their properties.
1. Basic Mapped Types
// Make all properties optionaltype Partial<T> = { [P in keyof T]?: T[P];};
// Make all properties readonlytype Readonly<T> = { readonly [P in keyof T]: T[P];};
// Practical example: Form state managementinterface UserForm { username: string; email: string; password: string;}
type UserFormErrors = Record<keyof UserForm, string[]>;type UserFormTouched = Record<keyof UserForm, boolean>;
const formState: { values: UserForm; errors: UserFormErrors; touched: UserFormTouched;} = { values: { username: "", email: "", password: "" }, errors: { username: [], email: [], password: [] }, touched: { username: false, email: false, password: false }};2. Advanced Mapped Types
// Conditional type mapping with filteringtype FilteredKeys<T, U> = { [P in keyof T as T[P] extends U ? P : never]: T[P];};
// Example: Extract method propertiesinterface ApiClient { get: (url: string) => Promise<any>; post: (url: string, data: any) => Promise<any>; token: string; baseUrl: string;}
type ApiMethods = FilteredKeys<ApiClient, Function>;// Result: { get: ..., post: ... }
// Template literal with mapped typestype Getters<T> = { [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];};
interface Person { name: string; age: number;}
type PersonGetters = Getters<Person>;// Result: { getName: () => string, getAge: () => number }Conditional Types
Conditional types select one of two possible types based on a condition.
1. Basic Conditional Types
// Type distribution in conditional typestype ToArray<T> = T extends any ? T[] : never;
// Extracting return typestype ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Practical example: API response typetype ApiEndpoint<T extends string> = T extends `/${infer Path}` ? { path: Path; method: "GET" | "POST" } : never;
// Usagetype UserEndpoint = ApiEndpoint<"/users">;// Result: { path: "users"; method: "GET" | "POST" }2. Advanced Conditional Types
// Complex type inferencetype UnwrapPromise<T> = T extends Promise<infer U> ? U extends Promise<any> ? UnwrapPromise<U> : U : T;
// Usagetype NestedPromise = Promise<Promise<Promise<string>>>;type Unwrapped = UnwrapPromise<NestedPromise>; // string
// Recursive conditional typestype DeepReadonly<T> = { readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];};
// Example usageinterface Config { api: { endpoint: string; timeout: number; }; features: { darkMode: boolean; notifications: { email: boolean; push: boolean; }; };}
type ReadonlyConfig = DeepReadonly<Config>;Template Literal Types
Template literal types combine literal types through template literal strings.
1. Basic Template Literals
// CSS properties typetype CSSValue = number | string;type CSSProperty = "margin" | "padding" | "border";type CSSDirection = "top" | "right" | "bottom" | "left";
type CSSRule = `${CSSProperty}${Capitalize<CSSDirection>}` | CSSProperty;
// Usageconst styles: Record<CSSRule, CSSValue> = { margin: 10, marginTop: "1rem", padding: "20px", paddingLeft: 15};2. Advanced Template Literals
// Event handling typestype EventType = "click" | "focus" | "blur" | "mouseover";type Handler<T extends string> = `on${Capitalize<T>}`;type EventHandler<T extends EventType> = Handler<T>;
interface ComponentProps { onClick?: () => void; onFocus?: () => void; onBlur?: () => void; onMouseover?: () => void;}
// Route parameter extractiontype RouteParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | RouteParams<Rest> : T extends `${string}:${infer Param}` ? Param : never;
// Usagetype UserRouteParams = RouteParams<"/users/:id/posts/:postId">;// Result: "id" | "postId"Index Types and Lookup Types
Index types and lookup types provide ways to work with the structure of objects and their property types.
1. Index Types
// Advanced index type patternsinterface API { endpoints: { users: { get: (id: string) => Promise<User>; post: (data: NewUser) => Promise<User>; }; posts: { get: (id: string) => Promise<Post>; delete: (id: string) => Promise<void>; }; };}
type EndpointMethods<T> = { [K in keyof T]: T[K] extends { [key: string]: any } ? EndpointMethods<T[K]> : T[K] extends Function ? K : never;}[keyof T];
type ApiMethods = EndpointMethods<API["endpoints"]>;2. Lookup Types
// Type-safe object pathstype PathImpl<T, K extends keyof T> = K extends string ? T[K] extends Record<string, any> ? K | `${K}.${PathImpl<T[K], keyof T[K]>}` : K : never;
type Path<T> = PathImpl<T, keyof T>;
// Usageinterface User { name: string; address: { street: string; city: string; country: { code: string; name: string; }; };}
type UserPath = Path<User>;// Result: "name" | "address" | "address.street" | "address.city" | "address.country" | "address.country.code" | "address.country.name"Utility Types
1. Built-in Utility Types
// Advanced usage of utility typesinterface User { id: number; name: string; email: string; password: string; preferences: { theme: "light" | "dark"; notifications: boolean; };}
// Combining utility typestype PublicUser = Omit<User, "password"> & { readonly id: number;};
type UserUpdate = Partial<Omit<User, "id">>;
type UserPreferences = Pick<User, "preferences">;2. Custom Utility Types
// Deep partial with recursive typestype DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]>;} : T;
// Mutable with conditional typestype Mutable<T> = { -readonly [P in keyof T]: T[P] extends object ? Mutable<T[P]> : T[P];};
// Type-safe path accessortype PathValue<T, P extends Path<T>> = P extends `${infer Key}.${infer Rest}` ? Key extends keyof T ? Rest extends Path<T[Key]> ? PathValue<T[Key], Rest> : never : never : P extends keyof T ? T[P] : never;
// Usagefunction getPath<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P> { return path.split('.').reduce((acc: any, part) => acc?.[part], obj);}Best Practices
-
Type Safety First
- Use precise types over any
- Leverage type inference when possible
- Use type guards for runtime type checking
-
Code Organization
- Keep type definitions close to where they’re used
- Use descriptive names for types
- Document complex type manipulations
-
Performance Considerations
- Avoid excessive use of complex conditional types
- Use type aliases for frequently used types
- Consider the impact on compilation time
-
Maintainability
- Keep type definitions DRY
- Use built-in utility types when available
- Document complex type patterns
-
Error Handling
- Use discriminated unions for error states
- Implement exhaustive checking
- Provide meaningful type errors
Common Patterns and Use Cases
- Type-Safe API Clients
type ApiRoutes = { "/users": { GET: { response: User[]; query: { limit: number } }; POST: { response: User; body: NewUser }; }; "/users/:id": { GET: { response: User; params: { id: string } }; PUT: { response: User; body: UserUpdate; params: { id: string } }; };};
type ApiClient = { [P in keyof ApiRoutes]: { [M in keyof ApiRoutes[P]]: ApiRoutes[P][M] extends { response: any } ? (config: Omit<ApiRoutes[P][M], "response">) => Promise<ApiRoutes[P][M]["response"]> : never; };};- Form Validation
type ValidationRule<T> = { validate: (value: T) => boolean; message: string;};
type FormValidation<T> = { [P in keyof T]: ValidationRule<T[P]>[];};
// Usageconst userValidation: FormValidation<User> = { email: [ { validate: (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), message: "Invalid email format" } ], password: [ { validate: (password) => password.length >= 8, message: "Password must be at least 8 characters" } ]};- State Management
type Action<T extends string = string, P = any> = { type: T; payload?: P;};
type ActionCreator<T extends string, P> = (payload: P) => Action<T, P>;
type Reducer<S, A extends Action> = (state: S, action: A) => S;
// Usageinterface CounterState { count: number;}
type CounterAction = | Action<"INCREMENT"> | Action<"DECREMENT"> | Action<"SET_COUNT", number>;
const counterReducer: Reducer<CounterState, CounterAction> = (state, action) => { switch (action.type) { case "INCREMENT": return { count: state.count + 1 }; case "DECREMENT": return { count: state.count - 1 }; case "SET_COUNT": return { count: action.payload }; default: return state; }};Conclusion
Advanced types in TypeScript provide powerful tools for creating type-safe and maintainable applications. By understanding and properly using these features, you can:
- Create more precise and self-documenting code
- Catch errors at compile-time rather than runtime
- Build reusable and type-safe components
- Improve code maintainability and readability
Remember to balance type safety with code complexity, and always choose the simplest type definition that meets your needs.
Series Navigation
- Previous: TypeScript Generics
- Next: TypeScript Decorators