Now we get to the good stuff. Combining mapped types with conditional types is where you stop just rearranging the furniture and start knocking down walls to redesign the whole house. This is the power tool that lets you surgically transform one type into another based on the characteristics of its properties, not just their names. It’s how you move from “make everything optional” to “make only the properties of a certain shape optional.”

The basic syntax is exactly what it sounds like: you take a mapped type and, instead of just spitting out T[P] for the property type, you run it through a conditional type. The mental model is a filter and a transformation pipeline: you iterate over each property (mapped type), and for each one, you ask a question about its type (conditional type), and then you decide what to output based on the answer.

The Basic Pattern: Filtering Properties

Let’s say you have a configuration object where some properties are meant to be functions (event handlers, validators, etc.) and others are just plain data. You want to create a type that only has the function properties, perhaps for a system that binds event listeners. Here’s how you do it:

type Config = {
  id: number;
  title: string;
  onInit: () => void;
  validate: (input: string) => boolean;
  maxRetries: number;
};

type FunctionProperties<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : never;
};

type ConfigFunctions = FunctionProperties<Config>;
// type ConfigFunctions = {
//   id: never;
//   title: never;
//   onInit: () => void;
//   validate: (input: string) => boolean;
//   maxRetries: never;
//}

Well, that’s… ugly and not very useful. We’ve got a bunch of never properties cluttering up the place. We asked “is this a function?” and if it was, we kept it, and if it wasn’t, we made it never. But we didn’t actually remove the property. For that, we need a second step.

Making it Useful: Remapping the Keys with as

This is where the as clause in mapped types (introduced in TypeScript 4.1) becomes your best friend. It allows you to filter out keys by remapping them to never. The pattern is to combine our conditional type with a key remapping.

type OnlyFunctionProperties<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
};

type CleanConfigFunctions = OnlyFunctionProperties<Config>;
// type CleanConfigFunctions = {
//   onInit: () => void;
//   validate: (input: string) => boolean;
// }

Beautiful. Let’s break down the magic inside the as clause:

  1. K in keyof T: We iterate over every key K in T.
  2. T[K] extends (...args: any[]) => any ? K : never: For each key, we check if its type T[K] is a function.
  3. If it is a function, we keep the key name K.
  4. If it is not a function, we remap the key to never. A key of type never is simply omitted from the final type.

This is the most common and powerful pattern you’ll use. It’s how you build types like Pick<T, Keys> or Omit<T, Keys> but based on the value’s type, not a predefined list of keys.

Transforming, Not Just Filtering

You’re not limited to just filtering. You can also transform the types of the properties you keep. A classic example is creating a type that unwraps all promises. This is a simplified version of Awaited<T> applied across an object.

type AsyncData = {
  user: Promise<{ name: string; id: number }>;
  settings: Promise<{ theme: string }>;
  log: string; // not a promise!
};

type UnwrapPromises<T> = {
  [K in keyof T]: T[K] extends Promise<infer U> ? U : T[K];
};

type ResolvedData = UnwrapPromises<AsyncData>;
// type ResolvedData = {
//   user: { name: string; id: number };
//   settings: { theme: string };
//   log: string;
// }

Here, the conditional type T[K] extends Promise<infer U> ? U : T[K] acts as the transformer. For each property, it asks: “Are you a Promise?” If yes, it extracts the resolved type U (using infer) and uses that. If not, it leaves the property type alone.

Watch Out for Unions and Ambiguity

Here’s the part where I have to be the brilliant but slightly annoying friend and point out the rough edges. Conditional types are distributive over unions. This usually works in your favor, but when combined with mapped types, it can lead to some head-scratching moments if you’re not prepared.

Consider this attempt to make only number properties nullable:

type MakeNumbersNullable<T> = {
  [K in keyof T]: T[K] extends number ? T[K] | null : T[K];
};

type Example = { a: number; b: string; c: number | string };
type Result = MakeNumbersNullable<Example>;
// type Result = { a: number | null; b: string; c: (number | string) | null }

Wait, why did c become (number | string) | null? Because the conditional type T[K] extends number is evaluated distributively. It checks number extends number (yes, add null) and string extends number (no, leave it), resulting in (number | null) | string, which TypeScript simplifies to number | string | null. This is likely what you wanted, but it’s crucial to understand why it happens. If you wanted to check if the entire union was a number, you’d need [T[K]] extends [number] to disable distributivity—a neat trick to have in your back pocket.

The real power here is in crafting precise, intention-revealing types. You’re not just describing data; you’re encoding rules and relationships directly into your type system. It makes your code more robust and your interfaces self-documenting. Just remember: with great power comes a great responsibility to write clear, tested type logic. Now go build something that would make the TypeScript architects proud. Or at least mildly impressed.