TypeScript Utility Types: Stop Rewriting Types You Already Have
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
TypeScript Utility Types: Stop Rewriting Types You Already Have
You define a User type. Then you need a CreateUserPayload — same shape, no id. Then a UpdateUserPayload — all fields optional except id. Then a UserPreview — just id, name, and email for list views. Then a PublicUser — everything except passwordHash.
Four types. One source of truth that has to stay in sync with all of them. Every time User gains a field, you chase down each derivative manually.
TypeScript's built-in utility types exist precisely to fix this. They let you derive new types from existing ones — so when User changes, all four derivatives update automatically.
Here's the full toolkit, with the production patterns you'll actually reach for.
The Problem: Manual Type Duplication
Here's what the naive approach looks like:
// Base type
interface User {
id: string;
name: string;
email: string;
passwordHash: string;
role: 'admin' | 'user' | 'moderator';
createdAt: Date;
updatedAt: Date;
}
// Now you manually duplicate and modify it for every use case:
interface CreateUserPayload {
name: string; // same
email: string; // same
passwordHash: string; // same
role: 'admin' | 'user' | 'moderator'; // same — already drifting risk
// forgot createdAt? updatedAt? id? Hope you remembered!
}
interface UpdateUserPayload {
id: string;
name?: string;
email?: string;
role?: 'admin' | 'user' | 'moderator';
// passwordHash deliberately omitted... but was it intentional or a bug?
}When someone adds a bio: string field to User, these derivative types don't update. You get a runtime mismatch with no compile-time warning.
Utility types solve this at the type level.
The Core Six
Partial<T> — Make everything optional
The canonical use case is PATCH/update payloads, where the caller only sends the fields they want to change.
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
// Instead of manually marking each field optional:
type UpdateUserPayload = Partial<User>;
// Equivalent to: { id?: string; name?: string; email?: string; role?: 'admin' | 'user' }
async function updateUser(id: string, patch: Partial<User>): Promise<User> {
return db.user.update({ where: { id }, data: patch });
}
// Usage:
await updateUser('u_123', { name: 'Alice' }); // fine
await updateUser('u_123', { role: 'admin' }); // fine
await updateUser('u_123', { email: 'a@b.com', name: 'Alice' }); // fine
await updateUser('u_123', { unknownField: 'x' }); // type errorOne gotcha: Partial is shallow. Partial<{ address: { city: string } }> makes address optional but city inside it stays required. For deep partial, you need a recursive type or a library like type-fest.
Required<T> — Remove all ? marks
The inverse of Partial. Useful when you know data is fully populated (post-validation, post-db-fetch) and want TypeScript to stop questioning you about possible undefineds.
interface SignupFormData {
name?: string;
email?: string;
acceptedTerms?: boolean;
}
// After validation, all fields are guaranteed present:
type ValidatedFormData = Required<SignupFormData>;
// { name: string; email: string; acceptedTerms: boolean }
function submitForm(data: SignupFormData) {
if (!data.name || !data.email || !data.acceptedTerms) {
throw new Error('Incomplete form');
}
// TypeScript still thinks data.name could be undefined here
// Cast to Required to fix:
processValidatedData(data as Required<SignupFormData>);
}Note the name: SignupFormData, not FormData. FormData is a global interface in lib.dom.d.ts, and interfaces with the same name in the same scope merge rather than conflict. Paste interface FormData { name?: string } into a file with no top-level import or export — a scratch file, a <script>, a snippet like this one — and you haven't declared a new type, you've added three optional fields to the DOM's FormData. keyof it and you get name | email | acceptedTerms | append | delete | get | getAll | has | set | forEach, so Required<FormData> is not the three-field object above and tsc --strict rejects that claim with TS2344. The merge doesn't happen inside a module, because a top-level import/export makes the interface file-local and it shadows the global instead — which is exactly why this bites in a scratch file and not in your app, and why it's confusing when it does. Response, Event, Request, Location, Selection and Range are all the same trap.
Pick<T, K> — Keep only these fields
Perfect for slimming down a fat object type to just what a component or function actually needs.
interface User {
id: string;
name: string;
email: string;
passwordHash: string;
role: 'admin' | 'user';
createdAt: Date;
}
// A list view only needs a few fields:
type UserListItem = Pick<User, 'id' | 'name' | 'email'>;
// { id: string; name: string; email: string }
// A component that only renders user info:
function UserCard({ user }: { user: UserListItem }) {
return <div>{user.name} — {user.email}</div>;
}
// This prevents accidentally passing passwordHash to a frontend component
// even if the full User object is available.Omit<T, K> — Remove these fields
When it's easier to say what you don't want than what you do want.
interface User {
id: string;
name: string;
email: string;
passwordHash: string;
role: 'admin' | 'user';
createdAt: Date;
updatedAt: Date;
}
// Creation payload: no id (server generates it), no timestamps
type CreateUserPayload = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// { name: string; email: string; passwordHash: string; role: 'admin' | 'user' }
// Public-facing type: strip sensitive data
type PublicUser = Omit<User, 'passwordHash'>;Pick vs Omit is a judgment call. Use Pick when you want a small subset (fewer keys to list). Use Omit when you want almost everything (fewer keys to exclude).
Record<K, V> — Typed dictionaries and maps
Record builds an object type where keys are K and values are V. It's the right tool any time you're using an object as a lookup table.
type Permission = 'read' | 'write' | 'delete' | 'admin';
// Without Record:
const loosePermissions: { [key: string]: boolean } = {
read: true,
write: false,
// typo: 'delte': false — silently allowed!
};
// With Record — keys are constrained to the union:
const userPermissions: Record<Permission, boolean> = {
read: true,
write: false,
delete: false,
admin: false,
// TypeScript error if you add an unknown key
// TypeScript error if you miss a key
};
// Common pattern: status → display config mapping
type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
pending: { label: 'Pending', color: 'yellow' },
processing: { label: 'Processing', color: 'blue' },
shipped: { label: 'Shipped', color: 'purple' },
delivered: { label: 'Delivered', color: 'green' },
cancelled: { label: 'Cancelled', color: 'red' },
};
// Adding a new OrderStatus will cause a compile error here until you add the config.
// That's the point.NonNullable<T> — Strip null and undefined
Useful after null checks, when you've already verified a value exists and want TypeScript to trust you.
type MaybeUser = User | null | undefined;
type DefiniteUser = NonNullable<MaybeUser>; // User
// Practical: narrowing after a guard
function processUsers(users: Array<User | null>) {
const validUsers = users.filter(Boolean);
// validUsers is still (User | null)[] — TypeScript can't infer the filter
const validUsers2 = users.filter((u): u is NonNullable<typeof u> => u !== null);
// Now validUsers2 is User[]
}The Inference Utilities
These extract types from existing code — particularly useful when working with third-party libraries that don't export all their types.
ReturnType<T> and Awaited<T>
// You have a function whose return type you want to reuse:
async function getUser(id: string) {
return db.user.findUnique({
where: { id },
select: { id: true, name: true, email: true, role: true },
});
}
// The return type is complex and Prisma doesn't export it directly.
// Derive it instead of duplicating it:
type UserQueryResult = Awaited<ReturnType<typeof getUser>>;
// = { id: string; name: string; email: string; role: string } | null
// NonNullable removes the null for when you know it exists:
type User = NonNullable<UserQueryResult>;
// Now if your query shape changes, User updates automatically.
function renderUser(user: User) {
return `${user.name} (${user.email})`;
}The Awaited type (TypeScript 4.5+) recursively unwraps Promise<T> to get T. Without it, ReturnType<typeof getUser> would give you Promise<User | null> rather than User | null.
Parameters<T>
Extracts the parameter types of a function as a tuple.
function createOrder(userId: string, items: CartItem[], couponCode?: string) {
// ...
}
type CreateOrderParams = Parameters<typeof createOrder>;
// [userId: string, items: CartItem[], couponCode?: string | undefined]
// Useful for wrapping functions without duplicating signatures:
function createOrderWithAudit(...args: Parameters<typeof createOrder>) {
auditLog('order_create', args[0]);
return createOrder(...args);
}
// Or extracting specific parameter types:
type OrderItems = Parameters<typeof createOrder>[1]; // CartItem[]Exclude<T, U> and Extract<T, U>
These operate on unions, not objects — a common point of confusion.
type AllRoles = 'admin' | 'moderator' | 'user' | 'guest';
// Exclude: remove members from a union
type StandardRoles = Exclude<AllRoles, 'admin'>;
// 'moderator' | 'user' | 'guest'
// Extract: keep only specific members
type PrivilegedRoles = Extract<AllRoles, 'admin' | 'moderator'>;
// 'admin' | 'moderator'
// Practical: filter a union to only string literals
type StringOrNumber = string | number | boolean | null;
type JustStrings = Extract<StringOrNumber, string>; // string
// Combining with Omit pattern for discriminated unions:
type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; message: string }
| { status: 'loading' };
type SuccessResponse<T> = Extract<ApiResponse<T>, { status: 'success' }>;
// { status: 'success'; data: T }Combination Patterns
The real power shows up when you compose these together.
Update payload with immutable ID
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
passwordHash: string;
}
// Immutable id, everything else optional, no password changes here
type UpdateProfilePayload = Partial<Omit<User, 'id' | 'passwordHash'>>;
// { name?: string; email?: string; role?: 'admin' | 'user' }
async function updateProfile(id: string, data: UpdateProfilePayload): Promise<User> {
return db.user.update({ where: { id }, data });
}Form state from a model
interface Product {
id: string;
name: string;
price: number;
description: string;
stock: number;
publishedAt: Date | null;
}
// Form values are always strings before parsing;
// id is server-generated; publishedAt is derived from a checkbox.
type ProductFormValues = Record<
keyof Omit<Product, 'id' | 'publishedAt'>,
string
>;
// { name: string; price: string; description: string; stock: string }Type-safe event handler map
type AppEvent = 'user:login' | 'user:logout' | 'order:placed' | 'order:cancelled';
type EventHandler = (payload: unknown) => void;
const handlers: Partial<Record<AppEvent, EventHandler>> = {
'user:login': (payload) => console.log('User logged in', payload),
// Not required to handle every event
};Inferring Prisma return types
This is one of the highest-use uses in Next.js / Node.js apps:
// prisma/queries.ts
export async function getPostWithAuthor(slug: string) {
return prisma.post.findUnique({
where: { slug },
include: { author: { select: { name: true, avatar: true } } },
});
}
// types.ts — derive the type, don't manually describe it
export type PostWithAuthor = NonNullable<
Awaited<ReturnType<typeof getPostWithAuthor>>
>;
// Now use it in your component:
function PostPage({ post }: { post: PostWithAuthor }) {
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
</article>
);
}When you change the Prisma include or select, PostWithAuthor updates automatically. Your component will break at compile time if you try to access a field that no longer exists — exactly the behavior you want.
When NOT to Use Utility Types
Don't use Partial as a lazy shortcut. If a function truly requires all fields, don't mark everything optional just to avoid writing out the interface. You lose compile-time guarantees and shift errors to runtime.
Don't over-chain them. Partial<Required<Omit<Pick<User, 'name' | 'email'>, 'email'>>> is unreadable. At that point, just write the type directly and add a comment.
Don't use Record<string, any> as a crutch for "I'm not sure about the shape." It defeats the purpose of TypeScript. At minimum use Record<string, unknown> and narrow where needed.
Don't use ReturnType when the library exports the type. If @prisma/client exports User, use that — don't infer it from a query function. Inferring makes sense when the shape is derived (includes, selects) and not directly exported.
Avoid Omit on discriminated unions. This is the one asymmetry worth memorising: Exclude and Extract distribute over a union, Pick and Omit do not. Omit<T, K> is defined as Pick<T, Exclude<keyof T, K>>, and keyof a union gives only the keys common to every member. So Omit on a union collapses to whatever is left of the shared keys — usually nothing.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number };
// keyof a union = only the keys present in every member
type ShapeKeys = keyof Shape; // 'kind' (not 'kind' | 'radius' | ...)
// So Omit doesn't "make radius optional" — it deletes the only key it can see
// and leaves you with an empty object type:
type WithoutKind = Omit<Shape, 'kind'>; // {} — keyof WithoutKind is neverThat empty type is assignable from anything object-shaped, so it silently accepts garbage instead of erroring. If you actually want per-branch omission, distribute it yourself:
type DistributiveOmit<T, K extends PropertyKey> =
T extends unknown ? Omit<T, K> : never;
type ShapeData = DistributiveOmit<Shape, 'kind'>;
// { radius: number } | { width: number; height: number }Extract needs no such help — it distributes already, which is why Extract<Shape, { kind: 'circle' }> gives you exactly { kind: 'circle'; radius: number }.
The Mental Model
Think of your base types as schemas and utility types as transforms on those schemas:
- Shape transforms:
Partial,Required,Readonly— change the optionality/mutability of fields - Field selection:
Pick,Omit— choose which fields survive - Key/value mapping:
Record— construct new shapes from key and value types - Union manipulation:
Exclude,Extract,NonNullable— filter union members - Inference:
ReturnType,Parameters,Awaited— extract types from code that already exists
Once you internalize these as transforms, composition becomes intuitive. You're not memorizing APIs — you're describing relationships between types.
Comments (0)
No comments yet. Be the first to share your thoughts!