TypeScript is everywhere. Chances are, you use it daily — yet most developers only scratch the surface of what it can do. They learn interface, type, Partial, Pick, maybe Omit, and call it a day.
But there's a whole arsenal of utility types sitting right there in TypeScript's standard library, waiting to make your code cleaner, safer, and dramatically more expressive.
Let me take you through the ones that genuinely changed how I write TypeScript — with real-world examples, not toy snippets.
Why Utility Types Matter
Before we dive in, let me set the scene.
You're maintaining a large codebase. You have a User type that's used everywhere — in forms, API responses, internal functions, database queries. The shape of User is slightly different in each context:
- The API returns everything including
passwordhashes - The form only needs name and email
- The database layer needs everything except computed fields
- The admin panel needs some fields to be readonly
Without utility types, developers create a dozen slightly-different interfaces by hand, copy-pasting and diverging. Someone adds a field to User and forgets to update UserForm and UserSummary. Bugs creep in. PRs get long.
Utility types let you derive types from other types — so your source of truth stays single.
The Classics You Already Know
Quick recap so we're on the same page:
type User = {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
role: 'admin' | 'user' | 'moderator';
};
// Partial — make all fields optional
type UserUpdate = Partial<User>;
// Required — make all fields required
type StrictUser = Required<User>;
// Pick — take only specific fields
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Omit — exclude specific fields
type SafeUser = Omit<User, 'password'>;
Good. Now let's go deeper.
Record<Keys, Type> — Stop Writing Repetitive Index Signatures
How many times have you written something like this?
type RolePermissions = {
admin: string[];
user: string[];
moderator: string[];
};
This is fragile. If role in your User type changes, this won't update automatically. Instead:
type RolePermissions = Record<User['role'], string[]>;
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete', 'manage_users'],
user: ['read'],
moderator: ['read', 'write', 'delete'],
};
Now RolePermissions is tied to the role union in User. Add a new role? TypeScript immediately tells you to add its permissions. This is the kind of safety net that prevents 2 AM production bugs.
ReturnType<T> — Type the Output, Not the Function
Here's a scenario I see constantly: someone writes a function, and then manually re-types its return value somewhere else.
// ❌ Don't do this
function getSession() {
return {
userId: 1,
token: 'abc123',
expiresAt: new Date(),
};
}
type Session = {
userId: number;
token: string;
expiresAt: Date;
};
Now Session and getSession can drift apart. Instead:
// ✅ Do this
function getSession() {
return {
userId: 1,
token: 'abc123',
expiresAt: new Date(),
};
}
type Session = ReturnType<typeof getSession>;
// Session is now: { userId: number; token: string; expiresAt: Date; }
Change the function? The type updates automatically. This is especially powerful when working with factory functions, hooks, or database query results.
Parameters<T> — Extract Function Arguments Like a Pro
Similar concept, but for function parameters:
function createUser(name: string, email: string, role: User['role']) {
// ...
}
type CreateUserArgs = Parameters<typeof createUser>;
// [name: string, email: string, role: 'admin' | 'user' | 'moderator']
// Grab a specific parameter
type UserRole = Parameters<typeof createUser>[2];
// 'admin' | 'user' | 'moderator'
This is gold when you're building wrappers or middleware that need to accept the same arguments as another function without duplicating the types.
Awaited<T> — Finally, Async Type Unwrapping
Async/await is everywhere, but typing async results used to be awkward. Awaited<T> resolves promises at any depth:
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// User (not Promise<User>)
Before Awaited, you'd have to unwrap manually or use conditional types. Now it's one line. It even handles nested promises:
type Nested = Awaited<Promise<Promise<Promise<string>>>>;
// string
NonNullable<T> — Banish null and undefined
When you're in a context where you've already null-checked, NonNullable communicates that intent:
type MaybeUser = User | null | undefined;
type DefiniteUser = NonNullable<MaybeUser>;
// User
This is particularly useful with ReturnType on functions that might return null:
function findUser(id: number): User | null {
// ...
}
type ExistingUser = NonNullable<ReturnType<typeof findUser>>;
// User
Extract<T, U> and Exclude<T, U> — Surgical Union Surgery
These are the scalpels of TypeScript. Extract keeps only the members of T that are assignable to U. Exclude removes them.
type AllRoles = 'admin' | 'user' | 'moderator' | 'guest';
type PrivilegedRoles = Extract<AllRoles, 'admin' | 'moderator'>;
// 'admin' | 'moderator'
type UnprivilegedRoles = Exclude<AllRoles, 'admin' | 'moderator'>;
// 'user' | 'guest'
Real-world use case — filtering discriminated unions:
type ApiResponse =
| { status: 'success'; data: User }
| { status: 'error'; message: string }
| { status: 'loading' };
type SuccessResponse = Extract<ApiResponse, { status: 'success' }>;
// { status: 'success'; data: User }
type ErrorOrLoading = Exclude<ApiResponse, { status: 'success' }>;
// { status: 'error'; message: string } | { status: 'loading' }
Combining Them — Where the Magic Happens
The real power comes from combining utility types. Here's a real pattern I use for API form handling:
type User = {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
role: 'admin' | 'user' | 'moderator';
};
// What the user can fill in a registration form
type RegistrationForm = Pick<
User,
'name' | 'email' | 'password'
>;
// What can be updated (no id, no timestamps, no role)
type UserUpdatePayload = Partial<
Omit<User, 'id' | 'createdAt' | 'updatedAt' | 'role'>
>;
// What's safe to send to the client
type PublicUser = Readonly<Omit<User, 'password'>>;
Three derived types. One source of truth. If User changes, all three update automatically.
Readonly<T> and ReadonlyArray<T> — Enforce Immutability
This one's underutilized. If a function shouldn't mutate its input, say so:
function displayUser(user: Readonly<User>) {
// user.name = 'hacker'; // ❌ TypeScript error!
console.log(user.name);
}
function processItems(items: ReadonlyArray<User>) {
// items.push(newUser); // ❌ TypeScript error!
return items.map(u => u.name);
}
This is documentation that's enforced by the compiler. New teammates can't accidentally mutate data they shouldn't.
Quick Reference
| Utility Type | What it does |
|---|---|
Partial<T> |
All fields optional |
Required<T> |
All fields required |
Readonly<T> |
All fields readonly |
Pick<T, K> |
Keep only keys K |
Omit<T, K> |
Remove keys K |
Record<K, V> |
Map K keys to V values |
ReturnType<F> |
Return type of function F |
Parameters<F> |
Parameters tuple of function F |
Awaited<T> |
Unwrap Promise |
NonNullable<T> |
Remove null and undefined |
Extract<T, U> |
Keep assignable members |
Exclude<T, U> |
Remove assignable members |
The Mindset Shift
Here's what changes once you internalize utility types: you stop writing types and start deriving them.
Every time you find yourself copy-pasting a type and making small modifications, stop. Ask: can I derive this from something I already have?
Nine times out of ten, the answer is yes.
Your types become a system — interconnected, self-consistent, and automatically kept in sync. That's when TypeScript stops feeling like overhead and starts feeling like a superpower.
Now go refactor that UserDTO you've been copy-pasting for three years. 😄
Did I miss your favorite utility type? Drop it in the comments — I'm always looking to learn new patterns.
Top comments (0)