Conditional and Mapped Types
Programming at the Type Level
You already know if/else and for loops in JavaScript. What if I told you TypeScript has the same thing — but for types? Want to say "if T is a string, use this type, otherwise use that type"? That's a conditional type. Want to transform every property in an object type? That's a mapped type. These two tools power every utility type in TypeScript's standard library, and once you learn them, you'll stop fighting the type system and start making it work for you.
Think of conditional types as a sorting machine on an assembly line. Each type comes down the belt, and the machine checks: "Does this type match pattern X?" If yes, route to output A. If no, route to output B. Mapped types are a stamping machine — they take every property in a type and stamp it with a transformation: make it readonly, make it optional, change its value type.
Conditional Types: T extends U ? X : Y
The syntax mirrors JavaScript's ternary operator, but operates on types:
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true (string literal extends string)
// Practical: extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type D = ElementOf<string[]>; // string
type E = ElementOf<number[]>; // number
type F = ElementOf<string>; // never (string is not an array)
Distributive Conditional Types
When T is a union and the conditional type checks T extends ..., TypeScript distributes the check over each union member individually:
type ToArray<T> = T extends unknown ? T[] : never;
// T = string | number distributes:
// ToArray<string> | ToArray<number>
// = string[] | number[]
type G = ToArray<string | number>; // string[] | number[]
// NOT (string | number)[] — it distributes over the union
Distribution only happens when the checked type is a naked type parameter — directly T extends .... If you wrap T in a tuple or array, distribution is prevented:
// Distributed
type Distributed<T> = T extends string ? "yes" : "no";
type R1 = Distributed<string | number>; // "yes" | "no"
// Non-distributed (wrapped in tuple)
type NonDistributed<T> = [T] extends [string] ? "yes" : "no";
type R2 = NonDistributed<string | number>; // "no" (union doesn't extend string)The [T] extends [string] trick is used when you want to check the union as a whole, not each member individually.
Mapped Types: { [K in keyof T]: ... }
Mapped types iterate over property keys and transform them:
// Make all properties optional
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make all properties required
type MyRequired<T> = {
[K in keyof T]-?: T[K]; // -? removes optionality
};
// Make all properties readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Make all properties mutable (remove readonly)
type Mutable<T> = {
-readonly [K in keyof T]: T[K]; // -readonly removes readonly
};
// Transform all property values to strings
type Stringify<T> = {
[K in keyof T]: string;
};
Key Remapping with as
TypeScript 4.1 added key remapping — you can transform the key names:
// Prefix all keys with 'get'
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// }
// Filter keys by remapping to never
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<User>;
// { name: string } — age is filtered out because number doesn't extend string
How key remapping to never filters properties
When a mapped type remaps a key to never, that property is removed from the resulting type. This is because never represents "no value" — a property with key never cannot exist. This gives us a filtering mechanism:
type FilterByType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
type OnlyFunctions<T> = FilterByType<T, Function>;
type OnlyNumbers<T> = FilterByType<T, number>;This pattern is the foundation of utility types like Pick and Omit — they select or exclude properties based on key or value conditions.
Combining Conditional and Mapped Types
The real power comes from combining both:
// Make only string properties optional, leave others required
type OptionalStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]?: T[K];
} & {
[K in keyof T as T[K] extends string ? never : K]: T[K];
};
// Deep readonly — recursively make everything readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K] // Don't recurse into functions
: DeepReadonly<T[K]>
: T[K];
};
interface Config {
database: {
host: string;
port: number;
credentials: {
user: string;
password: string;
};
};
features: string[];
}
type FrozenConfig = DeepReadonly<Config>;
// All nested properties are readonly — credentials.user is readonly too
Production Scenario: Form Validation Types
// A type that generates validation error types from a form shape
type ValidationErrors<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends any[]
? string // Arrays get a single error string
: ValidationErrors<T[K]> // Nested objects recurse
: string; // Primitives get a single error string
};
interface SignupForm {
name: string;
email: string;
age: number;
address: {
street: string;
city: string;
zip: string;
};
tags: string[];
}
type SignupErrors = ValidationErrors<SignupForm>;
// {
// name?: string;
// email?: string;
// age?: string;
// address?: {
// street?: string;
// city?: string;
// zip?: string;
// };
// tags?: string;
// }
// Type-safe form state
interface FormState<T> {
values: T;
errors: ValidationErrors<T>;
touched: { [K in keyof T]?: boolean };
isDirty: boolean;
isValid: boolean;
}
function useForm<T>(initial: T): FormState<T> {
// Implementation
}
-
Wrong: Forgetting that conditional types distribute over unions Right: Wrap in
[T] extends [...]to prevent distribution when you want to check the union as a whole -
Wrong: Using keyof on a union type and expecting all keys Right: keyof (A | B) gives only the SHARED keys — use keyof A | keyof B for all keys
-
Wrong: Forgetting -? and -readonly modifiers when removing optionality/readonly Right: Use -? to make optional props required, -readonly to make readonly props mutable
-
Wrong: Not handling the Function check in recursive mapped types Right: Check T[K] extends Function before recursing into objects
Build a DeepPartial Type
- 1Conditional types distribute over unions by default — wrap in [T] extends [...] to prevent distribution
- 2Mapped types iterate over keys with [K in keyof T] and can transform both keys (via as) and values
- 3Key remapping to never filters out properties — this is how Pick and Omit work internally
- 4Use -? to remove optionality and -readonly to remove readonly in mapped types
- 5keyof on a union gives shared keys only — use keyof A | keyof B for all keys across union members