DEV Community

Cover image for Mastering TypeScript Utility Types: Part 2 — The Transformers & Type Thieves
Manuj Sankrit
Manuj Sankrit

Posted on

Mastering TypeScript Utility Types: Part 2 — The Transformers & Type Thieves

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
Enter fullscreen mode Exit fullscreen mode

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 😭
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

Real stuff we can build with this:

  1. 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 });
Enter fullscreen mode Exit fullscreen mode
  1. 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' });
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Other places this saves us:

  1. 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);
    }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.**
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

Where else we use this:

  1. 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
}
Enter fullscreen mode Exit fullscreen mode
  1. 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);
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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

  1. Stop writing types twice. Let TypeScript infer.
  2. Partial + defaults = clean, flexible code
  3. Exclude/Extract = type-safe state machines
  4. 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> - When Partial only 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)