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:
-
Build
DeepPartial<T>from scratch (recursive optional everything) -
Build
DeepReadonly<T>from scratch (recursive immutability) -
Understand
infer(TypeScript's capture keyword) - Template Literal Types (type-safe string patterns)
-
The
Polymorphicpattern (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!
},
};
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];
};
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
};
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;
Breaking it down:
-
T extends object- Check ifTis an object type -
? { ... }- If YES, create mapped type with optional recursive properties -
: T- If NO (primitive likestringornumber), 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!
};
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
};
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
},
};
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
Not what I expected.
Step 1: The Obvious Attempt
Start with Readonly<T>:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
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;
The logic:
-
T extends Function- Is this a function? If YES → Don't touch it -
T extends object- Is this an object? If YES → Applyreadonlyrecursively -
: 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!
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
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
};
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;
I started breaking down the logic like this:
-
The Match: First, it checks "Is
Ta function?" -
The Capture: If it is,
infer Rtells TypeScript: "Don't just return true. Reach into the function, grab the specific type it returns, and store it in a temporary variableR." -
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"
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
-
extendsis the condition (the "if"). -
inferis 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)
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}`;
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')
2. CSS variables:
type CSSVariable = `--${string}`;
const color: CSSVariable = '--primary-color'; // ✅
const invalid: CSSVariable = 'primary-color'; // ❌ Error
3. API routes:
type APIRoute = `/api/${string}`;
const userRoute: APIRoute = '/api/users'; // ✅
const invalid: APIRoute = '/users'; // ❌ Error
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'
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>
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:
-
The Generic Element (
E): I tell TypeScript that my component can be any HTML tag. -
The
asProp: I pass in the tag name (like'a'or'button'). -
The Prop Thief: I use a built-in React type to "grab" all the valid props for whatever
Ehappens to be.
Peeking at the Magic: ComponentPropsWithRef
The real breakthrough for me was discovering ComponentPropsWithRef<E>. It's like an automated dictionary:
- If
Eis'a', it gives mehref. - If
Eis'button', it gives metypeanddisabled. - If
Eis'div', it removes both and gives meariaroles.
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;
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>
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
BoxorStack) and design system components (likeButtonorHeading). -
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' }>;
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 - ✅
inferpattern - Capturing types from generics - ✅ Template literal types - Type-safe string patterns
- ✅
Polymorphicpattern - How component libraries work
Key takeaways:
- Built-in utilities are shallow - we need custom ones for deep problems
-
inferlets us capture and extract types dynamically - Template literals enable compile-time string validation
-
Polymorphicis how libraries stay flexible AND type-safe - 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)