If you recall in Part 1, we covered the basics—Record, NonNullable, Pick, and Omit. The "sculptors" that help us shape our types.
Today? We're going deeper.
I remember the first time I used Partial<T> in a test utility. Thought it was neat. Then I discovered ReturnType<T> and Parameters<T>. Post this I haven't manually typed a function's return value since. These utilities don't just save us typing—they make it impossible for our types to drift out of sync with our actual code.
In this article, I will share my insights which is all about the Transformers & Type Thieves🤠. Utilities that automatically change existing types or straight-up "steal" type information from our functions.
Mental Model:
TRANSFORMERS TYPE THIEVES
----------- ------------
Partial<T> → ReturnType<T>
Required<T> → Parameters<T>
Exclude<T, U>
Extract<T, U>
Transform what Steal from what
we already have already exists
The Transformers
1. Partial<T>
Here's the thing that used to drive me crazy:
Writing tests without Partial is painful.
// We've got this complex interface
interface FetchOptions {
timeout: number;
retries: number;
cache: 'force-cache' | 'no-store' | 'default';
headers: Record<string, string>;
}
// And every single test needs ALL of it
function fetchData(url: string, options: FetchOptions) {
// ...implemenetation
}
// So we end up doing this:
fetchData('/api/users', {
timeout: 5000,
retries: 3,
cache: 'default',
headers: {},
});
// Like... we literally only care about timeout here 😭
In every test file we have to copy-paste which was really tedious. And when we add a new property to that interface? Cool, now go update 47 test files.
Here's what changed everything:
// Just make everything optional and use defaults
function fetchData(url: string, options: Partial<FetchOptions> = {}) {
const defaults: FetchOptions = {
timeout: 5000,
retries: 3,
cache: 'default',
headers: {},
};
const finalOptions = { ...defaults, ...options };
// ...implementation
}
// Now in tests:
fetchData('/api/users', { timeout: 10000 });
// ✅ Done. Just what we need.
Partial<T> just makes every property optional. It's like adding ? after every field. But we don't have to type it all out.
How we actually use this in our projects:
type TestOptions = {
timeout: number;
retries: number;
mockData: boolean;
verbose: boolean;
};
const defaultTestOptions: TestOptions = {
timeout: 5000,
retries: 3,
mockData: false,
verbose: false,
};
export const runTest = async (
testName: string,
testFn: () => Promise<void>,
options?: Partial<TestOptions>, // ← Here
): Promise<TestResult> => {
const finalOptions = {
...defaultTestOptions,
...options,
};
// ... rest of implementation
};
// Clean. Simple. Beautiful.
await runTest('user authentication', testUserAuth);
// or when we need to override something:
await runTest('user authentication', testUserAuth, { verbose: true });
Real stuff we can build with this:
- Mock factories for tests:
function createMockDOMRect(overrides: Partial<DOMRect> = {}): DOMRect {
return {
bottom: 0,
top: 0,
left: 0,
right: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...overrides,
};
}
// Just override what we need:
const rect = createMockDOMRect({ bottom: 100, width: 200 });
- Update functions (partial state updates):
function updateUserProfile(id: string, updates: Partial<UserProfile>) {
const currentUser = getUserById(id);
return { ...currentUser, ...updates };
}
// Clean partial updates:
updateUserProfile('123', { email: 'new@email.com' });
When to reach for it: Anytime we've got sensible defaults and want to override just what we need.
2. Required<T>
If Partial is about flexibility, Required is about validation. I thought of it like a Security Checkpoint. We might start with a "loose" object where everything is optional (like a web form while the user is still typing). But before we hit "Submit" and send that data to our database, we need to ensure everyone has their ID and tickets ready.
interface UserRegistrationForm {
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
}
/**
* The Gatekeeper:
* Using a "Type Predicate" (form is Required<...>)
*/
function validateRegistration(
form: UserRegistrationForm,
): form is Required<UserRegistrationForm> {
// We check if all fields actually have values
return !!(form.name && form.email && form.password && form.confirmPassword);
}
function submitRegistration(form: Required<UserRegistrationForm>) {
// TypeScript knows ALL fields exist here.
// No more optional chaining (form.name?) or 'if' checks needed!
console.log(form.name.toUpperCase());
}
The Secret Sauce: The Type Predicate (is)
You'll notice that strange syntax in the return type: form is Required<UserRegistrationForm>. This is called a Type Predicate. Normally, a function returns true or false, and TypeScript just sees it as a simple boolean. But by using is, we are signing a contract with the compiler. We're saying:
"Hey TypeScript, if this function returns true, I promise you that this form variable is no longer optional—it is now officially Required."
Why this is a game-changer: Without that is keyword, even if our function returned true, TypeScript would still be worried that name might be undefined inside the next block of code. The Type Predicate "promotes" our data from untrusted to fully verified, allowing us to write much cleaner logic in our success handlers.
When to reach for it:
- Final form submissions
- Validating API payloads before processing
- Turning "loose" user input into "strict" system data
3. Exclude<T, U>
The problem:
We've got a union representing all possible states. But some functions can't handle all of them.
// Every page type in our app
type PageType =
| 'home'
| 'about'
| 'products'
| 'blog'
| 'contact'
| 'admin-dashboard'
| 'admin-settings';
// Public routes shouldn't show admin stuff
// So we do this:
type PublicPageType = 'home' | 'about' | 'products' | 'blog' | 'contact';
// ❌ Now we've got duplication. PageType changes? Gotta update this too.
Not ideal.
Better way:
// Just filter out what we don't want
type PublicPageType = Exclude<PageType, 'admin-dashboard' | 'admin-settings'>;
// We can also do it like which is much cleaner
// type PublicPageType = Exclude<PageType, 'admin-${string}`>;
// Result: 'home' | 'about' | 'products' | 'blog' | 'contact'
Exclude<T, U> removes types from a union. Think of it like Array.filter() but for types. It keeps everything EXCEPT what we specify.
How we use this in routing:
const validPageTypes = [
'home',
'about',
'products',
'blog',
'contact',
'admin-dashboard',
'admin-settings',
] as const;
type AllPageTypes = (typeof validPageTypes)[number];
// Hide admin from public
type PublicPageType = Exclude<PageType, `admin-${string}`>;
// Validation helper
function isPublicPageType(pageType: string): pageType is PublicPageTypes {
const publicTypes: PublicPageTypes[] = [
'home',
'about',
'products',
'blog',
'contact',
];
return publicTypes.includes(pageType as PublicPageTypes);
}
Other places this saves us:
- State machines (removing final states):
type OrderStatus =
| 'pending'
| 'processing'
| 'shipped'
| 'delivered'
| 'cancelled';
// Only active orders can be modified
type ActiveOrderStatus = Exclude<OrderStatus, 'cancelled' | 'delivered'>;
function canModifyOrder(status: OrderStatus): status is ActiveOrderStatus {
const activeStatuses: ActiveOrderStatus[] = [
'pending',
'processing',
'shipped',
];
return activeStatuses.includes(status as ActiveOrderStatus);
}
4. Extract<T, U>
If Exclude is a bouncer kicking types out, Extract is a magnet. We hover it over a giant pile of types, and it only pulls out the ones that match our pattern.
The "Wildcard" Pattern:
This is where TypeScript gets really smart. Instead of picking types one by one, we can use Template Literal Types to "search" for a pattern.
type PageType =
| 'home'
| 'about'
| 'products'
| 'blog'
| 'contact'
| 'admin-dashboard'
| 'admin-settings';
// Using a "Wildcard" to get just the admin pages
type AdminPageType = Extract<PageType, `admin-${string}`>;
// Result: 'admin-dashboard' | 'admin-settings'
Why this is "Human-Friendly" code:
We are future-proofing our app. If we add 'admin-users' or 'admin-reports' to our PageType next month, our AdminPageType will automatically "magnetize" them. We don't have to change our code in two places!
Another useful pattern (Filtering Keys):
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
// Only pull out the keys we want to display in a specific UI
type DisplayKeys = Extract<keyof User, 'name' | 'email'>;
// Result: 'name' | 'email'
The Type Thieves
Okay, this is where it gets fun. Instead of writing types manually, we're gonna make TypeScript steal them from existing code.
5. ReturnType<T>
The old way (painful):
function getProductData(id: string) {
return {
id,
name: 'Widget',
price: 99.99,
inStock: true,
metadata: {
weight: 1.5,
dimensions: { width: 10, height: 5, depth: 3 },
},
};
}
// Need this type somewhere else...
// So we do this:
interface ProductData {
id: string;
name: string;
price: number;
inStock: boolean;
metadata: {
weight: number;
dimensions: {
width: number;
height: number;
depth: number;
};
};
}
// ❌ Duplication. Change the function? Update this interface too. This is asking for bugs.
The new way (beautiful):
function getProductData(id: string) {
return {
id,
name: 'Widget',
price: 99.99,
inStock: true,
metadata: {
weight: 1.5,
dimensions: { width: 10, height: 5, depth: 3 },
},
};
}
// Just steal the return type:
type ProductData = ReturnType<typeof getProductData>;
// ✅ Done. **Automatically synced forever.**
Changed the function? The type updates automatically. No manual work.
Real example from our API layer:
For this to work cleanly, we need a discriminated union where success and error states are distinct:
// API call with discriminated union return type
async function fetchProductsFromAPI(productIds: string[]) {
try {
const response = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify({ productIds }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Success case
return { success: true, products: data.products ?? [] } as const;
} catch (err) {
// Error case
return { success: false, error: err as Error } as const;
}
}
// Steal the return type:
type ApiProductsResponse = Awaited<ReturnType<typeof fetchProductsFromAPI>>;
// Result: { success: true, products: Product[] } | { success: false, error: Error }
// Extract just the success case:
type SuccessResponse = Extract<ApiProductsResponse, { success: true }>;
// **TypeScript now knows:** { success: true, products: Product[] }
// Extract just the error case:
type ErrorResponse = Extract<ApiProductsResponse, { success: false }>;
// **TypeScript now knows:** { success: false, error: Error }
Where else we use this:
- Redux action creators:
function createProductAction(id: string, name: string) {
return {
type: 'ADD_PRODUCT' as const,
payload: { id, name },
};
}
type ProductAction = ReturnType<typeof createProductAction>;
// Result: { type: 'ADD_PRODUCT'; payload: { id: string; name: string } }
// Reducer **automatically knows the shape:**
function productReducer(state: State, action: ProductAction) {
// TypeScript knows action.type and action.payload
}
- Form validators:
function validateUserForm(form: FormData) {
const errors: string[] = [];
if (!form.email) errors.push('Email required');
if (!form.password) errors.push('Password required');
return {
isValid: errors.length === 0,
errors,
};
}
type ValidationResult = ReturnType<typeof validateUserForm>;
// Use in component:
const [validation, setValidation] = useState<ValidationResult | null>(null);
When to use it: Literally anytime we need a function's return type elsewhere. One source of truth.
Bonus - combine with Awaited for async:
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Get the resolved type (unwrap the Promise):
type User = Awaited<ReturnType<typeof fetchUser>>;
6. Parameters<T>
Same problem, different angle:
function createUser(
name: string,
email: string,
age: number,
role: 'admin' | 'user',
) {
// ... implementation
}
// Need these param types somewhere else
// Manual way:
type CreateUserParams = {
name: string;
email: string;
age: number;
role: 'admin' | 'user';
};
// ❌ Function signature changes? Update this manually.
Better:
function createUser(
name: string,
email: string,
age: number,
role: 'admin' | 'user',
) {
// ... implementation
}
// Steal the params:
type CreateUserParams = Parameters<typeof createUser>;
// Result: [string, string, number, 'admin' | 'user']
// Grab individual ones:
type UserName = Parameters<typeof createUser>[0]; // string
type UserRole = Parameters<typeof createUser>[3]; // 'admin' | 'user'
Real usage - wrapper functions:
This is where Parameters really shines—when we need to preserve exact type signatures:
function logAndExecute<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
console.log(`Calling ${fn.name} with`, args);
return fn(...args);
}
// Usage:
function add(a: number, b: number): number {
return a + b;
}
const result = logAndExecute(add, 5, 10);
// **TypeScript automatically knows:**
// - Parameters<typeof add> = [number, number]
// - ReturnType<typeof add> = number
Another example - event handlers:
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// ...
};
type SubmitEventParams = Parameters<typeof handleSubmit>;
// Result: [React.FormEvent<HTMLFormElement>]
// Use when passing the handler around
When to use it: Anytime we need function parameter types. Stop duplicating.
⚡️ Bonus Round: The "Ghost" vs. The "Mold"
In Part 1, I mentioned Zod. When we combine Zod with ReturnType, we get the "Holy Grail" of coding: Single Source of Truth.
import { z } from 'zod';
// 1. THE MOLD (Runtime Reality)
const UserSchema = z.object({
name: z.string().min(5), // Let's require 5+ characters
email: z.string().email(),
});
// 2. THE GHOST (TypeScript Blueprint)
// Zod "steals" the shape of the mold and gives it to the Ghost.
type User = z.infer<typeof UserSchema>;
function submitUser(data: User) {
// Layer 1: The Ghost (TS) catches typos while we code.
// Layer 2: The Mold (Zod) catches fake data while the app runs.
return UserSchema.parse(data);
}
// 3. THE LOGBOOK (Utility Type)
// We "steal" the final, validated return type.
type SubmitUserReturn = ReturnType<typeof submitUser>;
Why this is beautiful: Think of TypeScript as a Ghost—it can see our code but it can't touch the real world. Think of Zod as a Mold—it's a physical check that sits in the real world.
By using z.infer and ReturnType, we lock the Ghost and the Mold together. If we change the Mold (the schema), the Ghost (the types) updates automatically. No manual interface updates, no sync errors, just pure automation.
Quick Cheat Sheet
| Utility | Does What | Mental Model |
|---|---|---|
Partial<T> |
Makes everything optional | Transform: all props → optional |
Required<T> |
Makes everything required | Transform: all props → required |
Exclude<T, U> |
Remove types from union | Filter OUT these types |
Extract<T, U> |
Keep only matching types | SELECT only these types |
ReturnType<T> |
Steal function return type | Thief: grab what function returns |
Parameters<T> |
Steal function params | Thief: grab what function accepts |
Awaited<T> |
Unwrap Promise | Thief: grab Promise's resolved type |
That's It
- Stop writing types twice. Let TypeScript infer.
-
Partial+ defaults = clean, flexible code -
Exclude/Extract= type-safe state machines -
ReturnType/Parameters= single source of truth
Up Next: Part 3
Built-in utilities are great but sometimes they're not enough.
In next and last part of this series I will share my insights on:
-
DeepPartial<T>- WhenPartialonly goes one level -
DeepReadonly<T>- Immutability all the way down -
Polymorphic<T, Props>- The holy grail for flexible React components - Template Literal Types - Type-safe string patterns
-
Building our own - Mapped types, conditional types,
infer
We'll build DeepPartial from scratch, break down the Polymorphic pattern step-by-step, and explore what powers libraries like Radix UI and others.
See you then. Happy typing! Pranipat 🙏! ☮️
Questions? Using these in production? Let me know in the comments! 🚀
Top comments (0)