DEV Community

Teguh Coding
Teguh Coding

Posted on

Mastering TypeScript Utility Types: The Hidden Gems Most Developers Ignore

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 password hashes
  • 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'>;
Enter fullscreen mode Exit fullscreen mode

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[];
};
Enter fullscreen mode Exit fullscreen mode

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'],
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

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'>>;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)