← Back to articles
Core TypeScriptReact

TypeScript Utility Types You Should Actually Use

· 6 min read

TypeScript’s type system goes far beyond annotating variables with string or number. The real power lives in utility types, built-in generics that transform existing types into new ones without duplicating definitions. Most codebases use two or three of them and ignore the rest. That’s a missed opportunity, because the right utility type can replace dozens of lines of manual type definitions and catch bugs that unit tests never would.

Partial and Required: toggling optionality

Partial<T> makes every property in a type optional. It’s the natural fit for update functions where you only send the fields that changed.

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

async function updateUser(id: string, fields: Partial<User>) {
  return db.user.update({ where: { id }, data: fields });
}

// Only send what changed
await updateUser('123', { name: 'Thomas' });

Required<T> does the opposite. It forces every optional property to be defined, which is useful when a config object has defaults but must be fully resolved before use.

interface AppConfig {
  apiUrl?: string;
  timeout?: number;
  retries?: number;
}

function resolveConfig(partial: AppConfig): Required<AppConfig> {
  return {
    apiUrl: partial.apiUrl ?? 'https://api.example.com',
    timeout: partial.timeout ?? 5000,
    retries: partial.retries ?? 3,
  };
}

The return type guarantees that every consumer of the resolved config gets defined values. No optional chaining needed downstream.

Pick and Omit: shaping types from existing ones

Pick<T, K> extracts a subset of properties. Instead of defining a new interface that duplicates half of an existing one, you derive it.

type UserCardProps = Pick<User, 'name' | 'email' | 'role'>;

function UserCard({ name, email, role }: UserCardProps) {
  return (
    <div>
      <h3>{name}</h3>
      <p>{email}</p>
      <span>{role}</span>
    </div>
  );
}

If User gains a new field, UserCardProps stays untouched. If name changes type from string to something else, UserCardProps updates automatically. The type stays in sync with its source without any manual effort.

Omit<T, K> works the other way, keeping everything except the specified keys. It’s ideal for props that forward most of an interface but handle some properties internally.

type CreateUserInput = Omit<User, 'id'>;

function CreateUserForm({ onSubmit }: { onSubmit: (data: CreateUserInput) => void }) {
  // id is generated server-side, the form doesn't need it
}

Record: typed dictionaries

Record<K, V> creates an object type where every key of type K maps to a value of type V. It replaces the loose { [key: string]: any } pattern with something that TypeScript can actually enforce.

type Role = 'admin' | 'editor' | 'viewer';

interface Permissions {
  canEdit: boolean;
  canDelete: boolean;
  canPublish: boolean;
}

const rolePermissions: Record<Role, Permissions> = {
  admin: { canEdit: true, canDelete: true, canPublish: true },
  editor: { canEdit: true, canDelete: false, canPublish: true },
  viewer: { canEdit: false, canDelete: false, canPublish: false },
};

If you add a new role to the Role union, TypeScript immediately flags the Record as incomplete. Every role must be accounted for. This turns a runtime “oops, we forgot the new role” into a compile-time error.

Extract and Exclude: filtering union types

Extract and Exclude operate on union types. Extract<T, U> keeps only the members of T that are assignable to U. Exclude<T, U> removes them.

type AppEvent =
  | { type: 'click'; x: number; y: number }
  | { type: 'keypress'; key: string }
  | { type: 'scroll'; offset: number }
  | { type: 'resize'; width: number; height: number };

// Only mouse-related events
type MouseEvent = Extract<AppEvent, { type: 'click' }>;

// Everything except scroll
type NonScrollEvent = Exclude<AppEvent, { type: 'scroll' }>;

This is particularly powerful in event systems and state machines where you need to narrow a broad union to a specific subset without rewriting the types.

ReturnType and Parameters: deriving from functions

When a function’s return type is complex or comes from a third-party library, ReturnType extracts it without requiring a separate type definition.

function useAuth() {
  return {
    user: getCurrentUser(),
    isAdmin: checkAdminRole(),
    logout: () => clearSession(),
  };
}

// Derived automatically, stays in sync if useAuth changes
type AuthContext = ReturnType<typeof useAuth>;

Parameters does the same for function arguments. It’s useful when wrapping functions or building middleware that needs to match a function’s signature.

function createUser(name: string, email: string, role: Role) {
  // ...
}

type CreateUserParams = Parameters<typeof createUser>;
// [name: string, email: string, role: Role]

Custom generics: building your own utility types

The built-in utilities are themselves just generic types. Building custom ones follows the same pattern and lets you encode your domain constraints into the type system.

type ApiResponse<T> = { status: 'success'; data: T } | { status: 'error'; error: string };

async function fetchUser(id: string): Promise<ApiResponse<User>> {
  try {
    const user = await db.user.findUnique({ where: { id } });
    return { status: 'success', data: user };
  } catch (e) {
    return { status: 'error', error: 'User not found' };
  }
}
// Make specific keys optional while keeping the rest required
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

type CreateUserInput = PartialBy<User, 'id' | 'role'>;
// id and role are optional, name and email are required

Custom generics compose with the built-in ones. PartialBy uses Omit, Partial, and Pick together to express a constraint that none of them can express alone. Once defined, it’s reusable across the entire codebase.

Conditional types: types that branch

Conditional types let you express “if A, then B, else C” at the type level. They power most of the advanced patterns in libraries like Zod, tRPC, and TanStack Router.

type IsArray<T> = T extends any[] ? true : false;

type A = IsArray<string[]>; // true
type B = IsArray<number>; // false

A more practical example: extracting the resolved type from a promise, regardless of nesting depth.

type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;

type Result = UnwrapPromise<Promise<Promise<string>>>;
// string

The infer keyword captures the inner type during pattern matching. Combined with recursion, it can peel through any number of wrapper types to get to the value inside.

Conclusion

TypeScript’s utility types aren’t academic abstractions. They’re practical tools that keep type definitions DRY, catch refactoring mistakes at compile time, and make impossible states truly impossible. The best TypeScript code doesn’t have more types. It has fewer, smarter types that derive from a single source of truth and propagate changes automatically.