TL;DR
- TypeScript ships 20+ built-in utility types. Knowing how they work (not just what they do) is key.
- Most are implemented with mapped types + conditional types.
satisfies(TS 4.9+) validates a value matches a type without widening it.constassertions freeze literal types.
Step 1: Object Transformation Utilities
Object transformation utilities (Partial, Required, Readonly) exist because the same data shape often needs different "modes" throughout an application: a user creation form needs all fields optional (partial input), the database record needs all fields required, and the API response should be readonly to prevent accidental mutation. Without these utilities, you'd duplicate type definitions for each variant. They're the most-used TypeScript utilities and appear in every codebase that handles forms, API responses, or state management.
Partial — Make all properties optional
// Implementation
type Partial<T> = { [K in keyof T]?: T[K] };
// Use case: Update functions where you only pass changed fields
interface User {
name: string;
email: string;
age: number;
}
function updateUser(id: string, changes: Partial<User>) {
// changes can have any subset of User properties
}
updateUser("1", { name: "Bob" }); // ✅
updateUser("1", { name: "Bob", age: 26 }); // ✅
updateUser("1", {}); // ✅
Required — Make all properties required
// Implementation
type Required<T> = { [K in keyof T]-?: T[K] };
// The -? removes the optional modifier
interface Config {
host?: string;
port?: number;
debug?: boolean;
}
// After defaults are applied, everything is guaranteed
const finalConfig: Required<Config> = {
host: "localhost",
port: 3000,
debug: false,
};
Readonly & DeepReadonly
// Built-in Readonly (shallow only)
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Deep readonly — common interview question
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K] // Don't make functions readonly
: DeepReadonly<T[K]>
: T[K];
};
interface Nested {
user: {
name: string;
address: {
city: string;
};
};
}
type Frozen = DeepReadonly<Nested>;
// user.name, user.address.city are all readonly — can't mutate at any depth
Step 2: Key Selection Utilities
Pick and Omit were created because real-world APIs almost never want the full object — a form needs 5 of 20 fields, a list view needs name and avatar but not the full profile, an API response should exclude internal fields. Without these utilities, you'd write manual interfaces for every "view" of your data. They enforce that selected keys actually exist on the source type, catching typos at compile time. These are among the most-used utilities in any TypeScript codebase.
Pick<T, K> — Select specific properties
// Implementation
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// API response — never expose password
type PublicUser = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }
Omit<T, K> — Remove specific properties
// Implementation
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// Remove sensitive fields
type SafeUser = Omit<User, "password">;
// Create DTO without id (server generates it)
type CreateUserDto = Omit<User, "id" | "createdAt">;
Record<K, V> — Object with known key/value types
// Implementation
type Record<K extends keyof any, T> = { [P in K]: T };
// Permission map
type Role = "admin" | "editor" | "viewer";
type Permissions = Record<Role, string[]>;
const perms: Permissions = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};
// Indexed state map
type LoadingState = Record<string, { loading: boolean; error?: string }>;
Step 3: Union Utilities
Union utilities (Exclude, Extract, NonNullable) manipulate union types the way filter/map work on arrays. They were needed because union types are central to TypeScript (every string | null, every discriminated union, every enum-like const) and you frequently need to add/remove members programmatically. Exclude removes types from a union, Extract keeps only matching types, and NonNullable strips null | undefined. They're the building blocks for creating domain-specific utility types.
Exclude<T, U> — Remove members from union
type Exclude<T, U> = T extends U ? never : T;
type Status = "pending" | "active" | "banned" | "deleted";
type ActiveStatuses = Exclude<Status, "banned" | "deleted">;
// "pending" | "active"
Extract<T, U> — Keep only matching members
type Extract<T, U> = T extends U ? T : never;
type Events = "click" | "scroll" | "mousemove" | "keydown";
type MouseEvents = Extract<Events, "click" | "scroll" | "mousemove">;
// "click" | "scroll" | "mousemove"
NonNullable — Remove null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>; // string
Step 4: Function Utilities
Function utilities (Parameters, ReturnType, Awaited) extract type information from function signatures. They were invented because in real codebases you often need to type things based on existing functions without duplicating their signatures: "this variable has the same type as what fetchUser returns" or "these args match what createHandler expects." They use infer under the hood to pull types out of function shapes, and they're essential for wrapper functions, middleware, and testing utilities.
Parameters — Extract parameter types as tuple
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number, admin: boolean) { /* ... */ }
type CreateUserParams = Parameters<typeof createUser>;
// [string, number, boolean]
// Useful for wrapping functions
function withLogging<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
console.log("Calling with:", args);
return fn(...args);
}
ReturnType — Extract return type
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<User>;
}
type FetchResult = Awaited<ReturnType<typeof fetchUser>>; // User
ConstructorParameters — Extract constructor args
class UserService {
constructor(private db: Database, private logger: Logger) {}
}
type ServiceDeps = ConstructorParameters<typeof UserService>;
// [Database, Logger]
Step 5: The satisfies Operator (TS 4.9+)
satisfies was the most-requested TypeScript feature for years because developers faced an impossible choice: type annotations give validation but widen types (losing literal inference), while no annotation preserves literals but doesn't validate. satisfies gives you both: it checks that your value matches a type without changing what TypeScript infers. This is perfect for configuration objects, route maps, and any constant where you want both type-safety AND precise autocomplete on the values.
satisfies validates that a value conforms to a type without widening the inferred type:
// ❌ Problem with type annotation — widens to Record<string, string | number>
const config: Record<string, string | number> = {
host: "localhost",
port: 3000,
};
config.host.toUpperCase(); // ❌ Error: might be number
// ✅ With satisfies — validates shape but keeps narrow literal types
const config = {
host: "localhost",
port: 3000,
} satisfies Record<string, string | number>;
config.host.toUpperCase(); // ✅ TS knows it's "localhost" (string)
config.port.toFixed(2); // ✅ TS knows it's 3000 (number)
// Practical: Route definitions
type Route = { path: string; method: "GET" | "POST" | "PUT" | "DELETE" };
const routes = {
getUser: { path: "/users/:id", method: "GET" },
createUser: { path: "/users", method: "POST" },
} satisfies Record<string, Route>;
// routes.getUser.method is "GET" not just string — narrowest possible type!
Step 6: const Assertions
as const was added because TypeScript's type widening (inferring string instead of "hello") is usually helpful but sometimes wrong. When you define a config object or enum-like constant, you want TypeScript to know the exact values, not just their types. Without as const, ["admin", "user"] is string[]; with it, it's readonly ["admin", "user"] — and you can derive a union type "admin" | "user" from it. This pattern eliminates the need for TypeScript enums in most cases.
as const makes TypeScript infer the narrowest possible type:
// Without as const
const colors = ["red", "green", "blue"];
// type: string[]
// With as const
const colors = ["red", "green", "blue"] as const;
// type: readonly ["red", "green", "blue"]
// Now you can derive types from values
type Color = (typeof colors)[number]; // "red" | "green" | "blue"
// Object with as const
const HTTP_STATUS = {
OK: 200,
NOT_FOUND: 404,
SERVER_ERROR: 500,
} as const;
type StatusCode = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS];
// 200 | 404 | 500
// Enum alternative (lighter weight)
const Direction = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
type Direction = (typeof Direction)[keyof typeof Direction];
// "UP" | "DOWN" | "LEFT" | "RIGHT"
Step 7: Building Custom Utility Types
Custom utility types are what make TypeScript scale in large codebases. The built-in utilities cover generic patterns, but every domain has its own: DeepPartial for nested form updates, Prettify for readable hover tooltips, StrictOmit that errors on invalid keys. Building these requires combining generics, conditional types, mapped types, and infer — all the advanced features working together. This is the level of TypeScript knowledge that library authors and senior engineers operate at.
DeepPartial — Recursively make optional
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface Config {
database: {
host: string;
port: number;
credentials: {
user: string;
password: string;
};
};
}
// Can pass any nested subset
function configure(overrides: DeepPartial<Config>) { /* ... */ }
configure({ database: { port: 5433 } }); // ✅
PickByType — Filter properties by value type
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
interface Mixed {
name: string;
age: number;
active: boolean;
email: string;
}
type StringProps = PickByType<Mixed, string>;
// { name: string; email: string }
MakeRequired — Make specific keys required
type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
interface FormData {
name?: string;
email?: string;
phone?: string;
}
type SubmitData = MakeRequired<FormData, "name" | "email">;
// { name: string; email: string; phone?: string }
Interview Questions
-
What's the difference between
PartialandDeepPartial?Partialonly makes top-level properties optional. Nested objects remain fully required.DeepPartialrecursively applies optionality at every depth.
-
When would you use
satisfiesvs a type annotation?- Use
satisfieswhen you want validation without losing narrow types. Use annotations when you explicitly want the wider type.
- Use
-
How do you create a type from runtime values?
- Use
as constto freeze the value, thentypeofto extract the type. Example:const x = [...] as const; type X = typeof x[number];
- Use
-
What's the difference between
Record<string, T>and{ [key: string]: T }?- Functionally identical.
Recordis more readable and composable with other utilities.
- Functionally identical.