StackPractices
advanced By Mathias Paulenko

Build Reusable Utility Types with Generics

How to create reusable TypeScript utility types using conditional types, mapped types, template literals, and generic constraints for type-safe APIs.

Topics: frontend

Overview

TypeScript’s type system supports conditional types, mapped types, and template literal types. Combined with generics, you can build utility types that derive new types from existing ones — Partial<T>, Pick<T, K>, Omit<T, K> are built-in examples. Custom utility types reduce duplication, enforce invariants at compile time, and make APIs more expressive without runtime overhead.

When to Use

  • Deriving form types from API response types (omit server-only fields)
  • Making specific properties required or optional based on context
  • Creating type-safe builders that enforce property assignment order
  • Extracting function parameter or return types for reuse
  • Building API client types from schema definitions

When NOT to Use

  • Simple applications with few types — built-in utilities (Partial, Pick, Omit) are sufficient
  • When a type alias is clearer — don’t create a utility type if a direct definition is more readable
  • Runtime validation — utility types are compile-time only; use Zod for runtime checks

Solution

Make specific properties required

type RequireFields<T, K extends keyof T> = T & {
  [P in K]-?: T[P];
};

interface User {
  id?: string;
  name?: string;
  email?: string;
  role: string;
}

type UserWithRequiredEmail = RequireFields<User, "id" | "email">;
// { id: string; name?: string; email: string; role: string }

Make specific properties optional

type OptionalFields<T, K extends keyof T> = Omit<T, K> & {
  [P in K]?: T[P];
};

interface CreateUserDTO {
  name: string;
  email: string;
  role: string;
  avatar: string;
}

type UpdateUserDTO = OptionalFields<CreateUserDTO, "name" | "email" | "role" | "avatar">;
// All fields optional — equivalent to Partial<CreateUserDTO>

Pick properties by value type

type PickByValue<T, ValueType> = {
  [P in keyof T as T[P] extends ValueType ? P : never]: T[P];
};

interface Config {
  port: number;
  host: string;
  debug: boolean;
  retries: number;
  logFile: string;
}

type StringConfig = PickByValue<Config, string>;
// { host: string; logFile: string }

type NumberConfig = PickByValue<Config, number>;
// { port: number; retries: number }

Omit properties by value type

type OmitByValue<T, ValueType> = {
  [P in keyof T as T[P] extends ValueType ? never : P]: T[P];
};

type NonBooleanConfig = OmitByValue<Config, boolean>;
// { port: number; host: string; retries: number; logFile: string }

Deep partial

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface AppConfig {
  server: {
    port: number;
    host: string;
  };
  database: {
    url: string;
    poolSize: number;
  };
  logging: {
    level: string;
    file: string;
  };
}

type PartialConfig = DeepPartial<AppConfig>;
// All properties optional at all levels
const config: PartialConfig = {
  server: { port: 3000 }, // host is optional
};

Deep readonly

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

type ReadonlyConfig = DeepReadonly<AppConfig>;
// All properties readonly at all levels

Template literal types

type EventName<T extends string> = `on${Capitalize<T>}`;

type ClickEvent = EventName<"click">; // "onClick"
type FocusEvent = EventName<"focus">; // "onFocus"

type EventHandler<T extends string> = {
  [K in EventName<T>]?: (event: { type: T; target: HTMLElement }) => void;
};

type ButtonHandlers = EventHandler<"click" | "focus" | "blur">;
// { onClick?: ...; onFocus?: ...; onBlur?: ... }

API response wrapper

type ApiResponse<T, E = string> =
  | { success: true; data: T; timestamp: number }
  | { success: false; error: E; timestamp: number };

type UserResponse = ApiResponse<{ id: string; name: string }>;
type ListResponse<T> = ApiResponse<T[], "not_found" | "server_error">;

Builder pattern with type safety

type Builder<T> = {
  [K in keyof T]: (value: T[K]) => Builder<Omit<T, K>>;
} & { build: () => T };

function createBuilder<T>(): Builder<T> {
  const state = {} as T;
  const proxy = {} as Builder<T>;

  for (const key in state) {
    proxy[key] = ((value: any) => {
      (state as any)[key] = value;
      return proxy;
    }) as any;
  }

  proxy.build = () => state;
  return proxy;
}

interface User {
  id: string;
  name: string;
  email: string;
}

const user = createBuilder<User>()
  .id("123")
  .name("Alice")
  .email("alice@example.com")
  .build();

Extract route parameters

type RouteParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param]: string } & RouteParams<Rest>
  : T extends `${string}:${infer Param}`
  ? { [K in Param]: string }
  : {};

type UserRoute = "/users/:userId/posts/:postId";
type UserRouteParams = RouteParams<UserRoute>;
// { userId: string; postId: string }

function navigate<T extends string>(
  route: T,
  params: RouteParams<T>
): string {
  let path = route;
  for (const [key, value] of Object.entries(params)) {
    path = path.replace(`:${key}`, value as string);
  }
  return path;
}

navigate("/users/:userId/posts/:postId", { userId: "123", postId: "456" });
// "/users/123/posts/456"

Mutable version of a type

type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

interface ReadonlyUser {
  readonly id: string;
  readonly name: string;
  readonly email: string;
}

type EditableUser = Mutable<ReadonlyUser>;
// { id: string; name: string; email: string } — no readonly

Function parameter and return types

type FirstParam<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never;
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type AllParams<T> = T extends (...args: infer P) => any ? P : never;

function createUser(name: string, age: number, email: string): { id: string } {
  return { id: "1" };
}

type Name = FirstParam<typeof createUser>; // string
type Result = ReturnType<typeof createUser>; // { id: string }
type Params = AllParams<typeof createUser>; // [string, number, string]

Variants

Using with Zod for schema-derived types

import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "user", "guest"]),
});

type User = z.infer<typeof UserSchema>;

type CreateUserInput = OptionalFields<Omit<User, "id">, "role">;
// { name: string; email: string; role?: "admin" | "user" | "guest" }

type AdminUser = RequireFields<User, "role"> & { role: "admin" };

Conditional type with infer for array element

type ArrayElement<T> = T extends (infer E)[] ? E : never;

type StringElement = ArrayElement<string[]>; // string
type NumberElement = ArrayElement<number[]>; // number
type NotArray = ArrayElement<string>; // never

Promise unwrapping

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

type Result = Awaited<Promise<string>>; // string
type Nested = Awaited<Promise<Promise<number>>>; // number (in TypeScript 4.5+)

Best Practices

  • For a deeper guide, see Complete Guide to TypeScript Advanced Types.

  • Name utility types descriptively — RequireFields<T, K> is clearer than T & { [P in K]-?: T[P] }

  • Add generic constraints (K extends keyof T) to catch invalid usage at compile time

  • Keep utility types small and composable — combine simple utilities instead of building complex ones

  • Test utility types with expect-type or simple type assertions — ensure they produce the expected shape

  • Document edge cases — what happens with optional properties, unions, or nested objects

  • Prefer built-in utilities when available — Partial, Required, Pick, Omit, Readonly cover most cases

Common Mistakes

  • Overcomplicating types: a 50-line utility type that could be a 5-line type alias. Readability matters.
  • Not testing utility types: a type that compiles doesn’t mean it produces the right shape. Use type assertions to verify.
  • Ignoring never: when a conditional type doesn’t match, it resolves to never. This can silently break mapped types.
  • Forgetting -?: removing optionality requires -? modifier. Without it, ? properties stay optional.
  • Not constraining generics: K extends keyof T prevents passing invalid keys. Without the constraint, TypeScript can’t catch typos.

Frequently Asked Questions

What is a conditional type?

A type that selects between two types based on a condition: T extends U ? X : Y. If T extends U, the result is X; otherwise Y. With infer, you can extract types from within other types.

What is a mapped type?

A type that transforms each property of an existing type: { [P in keyof T]: NewType }. You can add/remove readonly and ? modifiers with +/- prefixes.

Can utility types be used with enums?

Yes, but string literal unions are preferred. keyof typeof MyEnum gives the enum keys as a union type.

How do I debug a utility type?

Use type assertions to check the result:

type Test = RequireFields<User, "email">;
const test: Test = { id: "1", email: "a@b.com", role: "user" }; // Should compile
What is the difference between keyof T and keyof T & string?

keyof T includes number | symbol if T has those key types. keyof T & string restricts to string keys only, which is useful for template literal types.