DevLift
Back to Blog

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.

Admin
August 2, 20265 min read6 views

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 error

One 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.

Rendering diagram...

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 never

That 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!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read
Two pointers, one pass, O(n) — learn how the variable-size sliding window grows and shrinks to solve substring and subarray problems efficiently.
AdminAugust 2, 20264 min read