DEV Community

Cover image for Mastering TypeScript Utility Types: Part 3 — Building Custom Utilities
Manuj Sankrit
Manuj Sankrit

Posted on

Mastering TypeScript Utility Types: Part 3 — Building Custom Utilities

In Part 1, we covered the "Sculptors"—Record, NonNullable, Pick, and Omit.

In Part 2, we unlocked the "Transformers & Type Thieves"—Partial, Exclude, ReturnType, and Parameters.

But here's what I learned the hard way: TypeScript's built-in utilities only go one level deep.

Tried using Partial<T> on a deeply nested config object? It only makes the top-level properties optional. The nested ones? Still required. Same deal with Readonly<T>.

So we're building our own.

Today's journey:

  1. Build DeepPartial<T> from scratch (recursive optional everything)
  2. Build DeepReadonly<T> from scratch (recursive immutability)
  3. Understand infer (TypeScript's capture keyword)
  4. Template Literal Types (type-safe string patterns)
  5. The Polymorphic pattern (how component libraries like Radix UI work)

The Problem: Built-ins Don't Go Deep Enough

Here's a scenario I ran into recently:

// Complex config object (pretty common in large apps)
type AppConfig = {
  analytics: {
    enabled: boolean;
    trackingId: string | null;
  };
  api: {
    baseUrl: string;
    timeout: number;
    retries: {
      enabled: boolean;
      maxAttempts: number;
    };
  };
};

// For tests, we want to override specific nested values
// Try using Partial<T>:
type TestConfig = Partial<AppConfig>;

const testConfig: TestConfig = {
  api: {
    baseUrl: 'http://localhost:3000',
    // ❌ Error: Property 'timeout' is missing!
    // ❌ Error: Property 'retries' is missing!
  },
};
Enter fullscreen mode Exit fullscreen mode

The problem: Partial<AppConfig> only makes analytics? and api? optional. Once we start defining api, TypeScript still expects us to provide all its properties which is not helpful.


Building DeepPartial<T> - The Journey

Let me walk through how I figured this out.

Step 1: Starting Point

We know Partial<T> works like this:

type Partial<T> = {
  [P in keyof T]?: T[P];
};
Enter fullscreen mode Exit fullscreen mode

Translation: "For every property P in type T, make it optional (?), keep its type as T[P]."

Step 2: What If We Just... Recurse?

My first attempt:

type DeepPartial<T> = {
  [P in keyof T]?: DeepPartial<T[P]>;
  //                 ^^^^^^^^^^^ Recursively apply to nested objects
};
Enter fullscreen mode Exit fullscreen mode

Seemed logical. Apply DeepPartial to the value too.

Problem: This breaks spectacularly for primitives. What does DeepPartial<string> even mean? TypeScript freaks out.

Step 3: The Fix - Conditional Check

Ah. We need to ask: "Is this thing an object? If yes, recurse. If no, leave it alone."

export type DeepPartial<T> = T extends object
  ? {
      [P in keyof T]?: DeepPartial<T[P]>;
    }
  : T;
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

  1. T extends object - Check if T is an object type
  2. ? { ... } - If YES, create mapped type with optional recursive properties
  3. : T - If NO (primitive like string or number), keep as-is

Now it works:

type AppConfig = {
  analytics: {
    enabled: boolean;
    trackingId: string | null;
  };
  api: {
    baseUrl: string;
    timeout: number;
  };
};

type TestConfig = DeepPartial<AppConfig>;

const testConfig: TestConfig = {
  api: {
    baseUrl: 'http://localhost:3000',
    // ✅ timeout is now optional!
  },
  // ✅ analytics is optional too!
};
Enter fullscreen mode Exit fullscreen mode

Real-World Usage

Here's where this actually saved me:

1. Test fixtures:

// Full app translations structure
type Translations = {
  common: {
    buttons: {
      submit: string;
      cancel: string;
    };
    errors: {
      required: string;
      invalid: string;
    };
  };
  pages: {
    home: {
      title: string;
      subtitle: string;
    };
  };
};

// Locale-specific overrides - only define what's different
const frenchTranslations: DeepPartial<Translations> = {
  common: {
    buttons: {
      submit: 'Soumettre',
      // ✅ Don't need to define 'cancel'
    },
  },
  // ✅ Don't need to define 'pages' at all
};
Enter fullscreen mode Exit fullscreen mode

2. Feature flag overrides:

type FeatureFlags = {
  checkout: {
    expressPayment: boolean;
    guestCheckout: boolean;
  };
  search: {
    autocomplete: boolean;
    filters: {
      price: boolean;
      brand: boolean;
    };
  };
};

// Override just what we need for this environment
const stagingFlags: DeepPartial<FeatureFlags> = {
  checkout: {
    expressPayment: true,
    // ✅ guestCheckout keeps default
  },
};
Enter fullscreen mode Exit fullscreen mode

Why this is powerful: We can override any nested property without rebuilding the entire structure. Perfect for:

  • Test fixtures
  • Locale translations
  • Environment-specific configs
  • Theme customization

Building DeepReadonly<T> - Going Immutable

Next challenge: deep immutability.

The Problem I Ran Into

const config = {
  api: {
    endpoints: {
      users: '/api/users',
      products: '/api/products',
    },
  },
} as const;

// Surprise! 'as const' only goes one level deep
config.api.endpoints.users = '/hacked'; // ❌ This actually works! Bug waiting to happen
Enter fullscreen mode Exit fullscreen mode

Not what I expected.

Step 1: The Obvious Attempt

Start with Readonly<T>:

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};
Enter fullscreen mode Exit fullscreen mode

Translation: "For every property P in T, make it readonly."

Step 2: Add Recursion + A Critical Catch

Here's the thing I learned: Functions are objects in JavaScript. If we blindly recurse into functions, we break them. Can't have a readonly function.

export type DeepReadonly<T> = T extends Function
  ? T // Stop! Leave functions alone
  : T extends object
    ? {
        readonly [P in keyof T]: DeepReadonly<T[P]>;
      }
    : T;
Enter fullscreen mode Exit fullscreen mode

The logic:

  1. T extends Function - Is this a function? If YES → Don't touch it
  2. T extends object - Is this an object? If YES → Apply readonly recursively
  3. : T - Otherwise (primitive) → Keep as-is

Now it actually works:

type AppConfig = {
  api: {
    endpoints: {
      users: string;
      products: string;
    };
  };
  logger: (message: string) => void;
};

type ReadonlyConfig = DeepReadonly<AppConfig>;

const config: ReadonlyConfig = {
  api: {
    endpoints: {
      users: '/api/users',
      products: '/api/products',
    },
  },
  logger: (msg) => console.log(msg),
};

config.api.endpoints.users = '/hacked'; // ❌ Error: Cannot assign to 'users' - it's read-only
config.logger('test'); // ✅ Functions still work!
Enter fullscreen mode Exit fullscreen mode

Where I Actually Use This

1. Configuration constants:

type AppSettings = {
  features: {
    darkMode: boolean;
    notifications: boolean;
  };
  limits: {
    maxFileSize: number;
    maxUploads: number;
  };
};

// Make entire config immutable
export const settings: DeepReadonly<AppSettings> = {
  features: {
    darkMode: true,
    notifications: false,
  },
  limits: {
    maxFileSize: 5242880, // 5MB
    maxUploads: 10,
  },
};

// Anywhere in the app
settings.limits.maxFileSize = 999; // ❌ Error - protected from accidental changes
Enter fullscreen mode Exit fullscreen mode

2. Redux/state management:

type AppState = {
  user: {
    profile: {
      name: string;
      email: string;
    };
    preferences: {
      theme: 'light' | 'dark';
    };
  };
};

// State should never be mutated directly
const initialState: DeepReadonly<AppState> = {
  // ... state definition
};
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • Prevent accidental mutations in state trees
  • Protect configuration from runtime changes
  • Make immutability explicit in the type system
  • Catch bugs at compile time instead of runtime

The infer Keyword — TypeScript's "Capture" Tool

This was the part that took me the longest to wrap my head around. I used to think of types as static labels I had to manually apply, but infer changed that. I started seeing it as a Type Detective.

Instead of telling TypeScript what a type is, I'm asking it to go find a type that's hidden inside a structure I've already built.

The Analogy That Fixed It For Me

I found it helpful to think of a sealed box labeled "Container of [Something]".

  • With standard generics, I'm the one putting the label on: Container<Apple>.
  • With infer, I'm looking at a box that's already there and saying: "If this is a container, capture whatever is inside it and let me use that value as a new type."

Peeking Under the Hood: ReturnType

I realized I had actually been using infer all along without knowing it. The built-in ReturnType<T> utility which we discussed in part 2 isn't a special hard-coded feature; it's just a clever bit of logic in the TypeScript source code that looks like this:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
Enter fullscreen mode Exit fullscreen mode

I started breaking down the logic like this:

  1. The Match: First, it checks "Is T a function?"
  2. The Capture: If it is, infer R tells TypeScript: "Don't just return true. Reach into the function, grab the specific type it returns, and store it in a temporary variable R."
  3. The Result: Then it simply returns that captured R.

The Real-World "Why": The Single Source of Truth

The moment this actually became useful for me was when I tried to keep my runtime data and my types in sync. I hated typing the same list twice.

For example, if I have a Set of allowed User Roles, I want my Role type to update automatically whenever I change that Set.

// 1. My source of truth (at runtime)
const VALID_ROLES = new Set(['admin', 'editor', 'viewer'] as const);

// 2. My "stolen" type logic
type GetSetValues<T> = T extends Set<infer V> ? V : never;

// 3. Automated Syncing
type Role = GetSetValues<typeof VALID_ROLES>;
// Result: "admin" | "editor" | "viewer"
Enter fullscreen mode Exit fullscreen mode

The breakthrough for me: Now, if I add 'super-admin' to the VALID_ROLES set, my Role type updates instantly. No manual syncing, no room for "type drift."

When I Find Myself Reaching For It

I've started using infer whenever I need to "break into" a type that I didn't define myself:

  • Unwrapping Promises: Getting the raw data type out of an API call result.
  • Peeking at Array Items: Finding out what's inside a list without manually re-typing the interface.
  • Template Literals: Pulling a specific ID or string pattern out of a string like "user_123".

My Mental Shortcut

  • extends is the condition (the "if").
  • infer is the grab (the "capture").

Template Literal Types - Type-Safe Strings

The Pattern

// Type-safe filter parameters
type FilterParam = `filter_${string}_single` | `filter_${string}_multi`;

// Valid:
const param1: FilterParam = 'filter_color_single'; // ✅
const param2: FilterParam = 'filter_price_multi'; // ✅

// Invalid:
const param3: FilterParam = 'filter_color_xyz'; // ❌ Error
const param4: FilterParam = 'color_single'; // ❌ Error (missing prefix)
Enter fullscreen mode Exit fullscreen mode

How It Works

Template literal types let us construct types using string patterns:

type Prefix = 'filter_';
type Suffix = '_single' | '_multi';
type FilterParam = `${Prefix}${string}${Suffix}`;
Enter fullscreen mode Exit fullscreen mode

The magic: TypeScript enforces the pattern at compile time.

Real-World Use Cases

1. Event handlers:

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

const handler1: EventName = 'onClick'; // ✅
const handler2: EventName = 'onSubmit'; // ✅
const handler3: EventName = 'click'; // ❌ Error (missing 'on')
Enter fullscreen mode Exit fullscreen mode

2. CSS variables:

type CSSVariable = `--${string}`;

const color: CSSVariable = '--primary-color'; // ✅
const invalid: CSSVariable = 'primary-color'; // ❌ Error
Enter fullscreen mode Exit fullscreen mode

3. API routes:

type APIRoute = `/api/${string}`;

const userRoute: APIRoute = '/api/users'; // ✅
const invalid: APIRoute = '/users'; // ❌ Error
Enter fullscreen mode Exit fullscreen mode

4. Extract admin routes:

type PageType =
  | 'home'
  | 'about'
  | 'admin-dashboard'
  | 'admin-settings'
  | 'admin-users';

// Only admin pages
type AdminPage = Extract<PageType, `admin-${string}`>;
// Result: 'admin-dashboard' | 'admin-settings' | 'admin-users'
Enter fullscreen mode Exit fullscreen mode

The Polymorphic Pattern — Building a "Shape-Shifter"

When this pattern clicked, I realized it's the secret sauce behind every professional UI library like Radix. The same insights I want to share too.

The Problem

I built a standard Button component, and everything was fine. Then a designer asked: "Can this button be a link sometimes?" I tried the obvious approach—adding an href prop and an if statement—but I quickly ran into a mess. My Button was getting link props, and my Link was getting button props. TypeScript was confused, and I was manually writing dozens of optional props.

I wanted a component that could do this:

// This should only allow button props (like type="submit")
<Button as="button" type="submit">Save</Button>

// This should only allow link props (like href="/home")
<Button as="a" href="/home">Home</Button>
Enter fullscreen mode Exit fullscreen mode

How I Simplified the Logic

I realized a polymorphic component is basically a shape-shifter. It needs to look at the as prop and then "steal" the correct HTML attributes for that specific tag.

I broke it down into this mental model:

  1. The Generic Element (E): I tell TypeScript that my component can be any HTML tag.
  2. The as Prop: I pass in the tag name (like 'a' or 'button').
  3. The Prop Thief: I use a built-in React type to "grab" all the valid props for whatever E happens to be.

Peeking at the Magic: ComponentPropsWithRef

The real breakthrough for me was discovering ComponentPropsWithRef<E>. It's like an automated dictionary:

  • If E is 'a', it gives me href.
  • If E is 'button', it gives me type and disabled.
  • If E is 'div', it removes both and gives me aria roles.

My "Clean" Implementation

Instead of fighting the types, I found that casting the component as a Polymorphic type at the very end was the cleanest way to keep the code readable.

// I think of this as the "Contract"
type Polymorphic<DefaultElement extends ElementType, CustomProps = {}> =
  E extends ElementType = DefaultElement
>(
  // Combine the 'as' prop + my custom props + the "stolen" HTML props
  props: { as?: E } & CustomProps & ComponentPropsWithRef<E>
) => ReactElement | null;
Enter fullscreen mode Exit fullscreen mode

The Result

When I finally put it all together, the developer experience was incredible. If I try to pass a disabled prop to a Button that is acting as="a", TypeScript catches it immediately:

<Button as="a" href="/dashboard" disabled>
  {/* ❌ Error: 'disabled' doesn't exist on 'a' tags! */}
</Button>
Enter fullscreen mode Exit fullscreen mode

When I Use This (And When I Don't)

I've learned that this is a "power tool."

  • I use it for: Layout primitives (like a Box or Stack) and design system components (like Button or Heading).
  • I skip it for: Simple, one-off components. If a component is always going to be a form, there's no need to make it polymorphic.

My rule of thumb: If I find myself writing if (isLink) return <a ... />, it's time to reach for the Polymorphic pattern.


Putting It All Together

Here's how these patterns compose:

// 1. Deep config with immutability
type Config = DeepReadonly<{
  api: {
    baseUrl: string;
    timeout: number;
  };
}>;

// 2. Test overrides
const testConfig: DeepPartial<Config> = {
  api: {
    timeout: 1000,
    // ✅ baseUrl is optional
  },
};

// 3. Type-safe routes
type APIRoute = `/api/${string}`;
type AdminRoute = Extract<APIRoute, `/api/admin-${string}`>;

// 4. Set-based types
const validRoutes = new Set(['/api/users', '/api/products'] as const);
type SetValues<S> = S extends Set<infer V> ? V : never;
type ValidRoute = SetValues<typeof validRoutes>;

// 5. Polymorphic component
const Link = forwardRef(({ ...props }, ref) => <a ref={ref} {...props} />);
export const StyledLink = Link as Polymorphic<'a', { variant: 'primary' | 'secondary' }>;
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Pattern Syntax Use Case
DeepPartial T extends object ? { [P]?: DeepPartial } : T Nested config overrides
DeepReadonly T extends Function ? T : readonly Immutable state trees
infer (Set) S extends Set<infer V> ? V : never Extract Set value types
infer (Promise) T extends Promise<infer U> ? U : T Unwrap Promise types
Template Literals `${Prefix}${string}${Suffix}` Type-safe string patterns
Polymorphic <E extends ElementType>(props) => Element Flexible React components

Wrapping Up

What we built:

  • DeepPartial<T> - Recursive optional properties
  • DeepReadonly<T> - Recursive immutability
  • infer pattern - Capturing types from generics
  • Template literal types - Type-safe string patterns
  • Polymorphic pattern - How component libraries work

Key takeaways:

  1. Built-in utilities are shallow - we need custom ones for deep problems
  2. infer lets us capture and extract types dynamically
  3. Template literals enable compile-time string validation
  4. Polymorphic is how libraries stay flexible AND type-safe
  5. These patterns compose - mix and match to solve complex problems

The Journey

We started with basic sculptors (Record, Pick, Omit).

Evolved to transformers and thieves (Partial, ReturnType, Parameters).

Ended by building our own utilities and understanding what powers modern TypeScript libraries.

We now have tools to:

  • Write type-safe, maintainable code
  • Build custom utility types when needed
  • Read advanced TypeScript codebases
  • Contribute to component libraries confidently

Final Thought

TypeScript's type system isn't just about catching bugs. It's about encoding our intent so clearly that code documents itself.

When we use DeepReadonly on a config, we're saying: "This should never change at runtime."

When we use Polymorphic on a Button, we're saying: "This can be many things, but always safely."

That's the real power.

Until next time, happy typing! Pranipat 🙏! ☮️


Questions about advanced TypeScript patterns? Want to discuss these utilities? Drop a comment! 🚀


Top comments (0)