Mapped, Conditional and Template Literal Types
Advanced type-level tools: mapped types, conditional types with infer, template literal types, and how the built-in utility types are made from them.
Types made from other types
Most of the time you write types by hand: an interface for a meter reading, a union of ticket statuses. In a growing code base, many types are variations on the same shape. A form needs every field of a customer as optional; an API answer leaves out the internal fields; an event system needs one handler per event name. Writing each variation by hand works, until someone adds a field to the original and forgets one of the copies.
TypeScript lets you compute such types from the original.
The building blocks are on earlier pages:
generics give types parameters,
keyof and indexed access
read keys and property types, and
unions and literal types
describe sets of values. This page adds the three tools that combine
them: mapped types, conditional types and template literal types.
Together they are what the
utility types are made of.
Mapped types
A mapped type is an object type
that is created by looping over a set of keys, usually
keyof T, and giving each key a property type. The
syntax looks like an index signature with in:
{ [K in keyof T]: NewType } reads as "for every key
K of T, a property K of type
NewType". Here every field of a meter reading becomes a
Boolean that says whether the field was changed:
interface MeterReading {
meterId: string;
kWh: number;
estimated: boolean;
}
// For every key K of MeterReading, make a property of type boolean.
type ChangedFields<T> = { [K in keyof T]: boolean };
const changes: ChangedFields<MeterReading> = {
meterId: false,
kWh: true,
estimated: false,
};
const changed = Object.entries(changes)
.filter(([, isChanged]) => isChanged)
.map(([field]) => field);
console.log(changed);
[ 'kWh' ]
ChangedFields<MeterReading> is the type
{ meterId: boolean; kWh: boolean; estimated: boolean }.
When someone adds a field to MeterReading, the compiler
requires it in every ChangedFields object as well, with
no extra work.
Adding and removing readonly and ?
Inside a mapped type, T[K] is the original property
type, so { [K in keyof T]: T[K] } is an exact copy. The
interesting part is what you put around it.
readonly and ? in front of or after the
brackets add those modifiers to every property; with a minus sign,
-readonly and -?, they are removed:
interface Tariff {
readonly code: string;
pricePerKWh?: number;
}
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Complete<T> = { [K in keyof T]-?: T[K] };
const draft: Mutable<Tariff> = { code: "DAY" };
draft.code = "NIGHT";
const full: Complete<Tariff> = { code: "DAY", pricePerKWh: 0.32 };
console.log(draft, full);
{ code: 'NIGHT' } { code: 'DAY', pricePerKWh: 0.32 }
In Complete<Tariff> the price is no longer
optional: leave it out and the compiler reports
Property 'pricePerKWh' is missing. A mapped type over
keyof T also keeps the modifiers it does not touch, so
a copy of a readonly property stays readonly unless you remove it.
Key remapping with as
Key remapping is an
as clause inside a mapped type that gives each new
property a different name than the key it came from:
[K in keyof T as NewName]. The new name is usually a
template literal type (see below). This turns every field of a
customer into a getter function:
interface Customer {
id: string;
name: string;
monthlyKWh: number;
}
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
function makeGetters(c: Customer): Getters<Customer> {
return {
getId: () => c.id,
getName: () => c.name,
getMonthlyKWh: () => c.monthlyKWh,
};
}
const g = makeGetters({ id: "C-2201", name: "Lucía Romero", monthlyKWh: 212 });
console.log(g.getName(), g.getMonthlyKWh());
Lucía Romero 212
Capitalize makes the first letter uppercase, so
"monthlyKWh" becomes "getMonthlyKWh". The
string & K is needed because keys can also be
numbers or symbols, and Capitalize only accepts
strings. Forget a getter, and the error names the property the
compiler calculated:
interface Customer {
id: string;
name: string;
monthlyKWh: number;
}
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
const g: Getters<Customer> = {
getId: () => "C-2201",
getName: () => "Lucía Romero",
};
example.ts(11,7): error TS2741: Property 'getMonthlyKWh' is missing in type '{ getId: () => string; getName: () => string; }' but required in type 'Getters<Customer>'.
When the as clause produces never, the key
is dropped. Combined with a conditional type, this filters
properties, here keeping only the text fields of a ticket, minus an
internal note that must not leave the service desk:
interface Ticket {
id: string;
title: string;
openedAt: string;
priority: number;
internalNote: string;
}
// Keep only the keys whose value type is string, and drop internalNote.
type PublicStrings<T> = {
[K in keyof T as T[K] extends string ? (K extends "internalNote" ? never : K) : never]: T[K];
};
const view: PublicStrings<Ticket> = {
id: "INC-30412",
title: "Portal login slow",
openedAt: "2026-10-05T08:12:00Z",
};
console.log(Object.keys(view).join(", "));
id, title, openedAt
Conditional types
A conditional type chooses
between two types with the same shape as the conditional operator in
JavaScript: T extends U ? X : Y. If T is
assignable to U, the result is X,
otherwise Y. "Extends" here means "fits into", the same
test as when you assign a value of type T to a variable
of type U:
type Unit<T> = T extends number ? "kWh" : "text";
const a: Unit<4.2> = "kWh";
const b: Unit<"E-1001"> = "text";
const c: Unit<number> = "text";
example.ts(5,7): error TS2322: Type '"text"' is not assignable to type '"kWh"'.
4.2 and number fit into
number, so both give "kWh"; only
"E-1001" gives "text". The third line is
the mistake. Conditional types can be chained like
else if, and they are most useful inside a mapped type,
where they decide something per property:
// The type of the value a meter field holds, decided by the field's type.
type FieldKind<T> =
T extends number ? "numeric" :
T extends string ? "text" :
T extends boolean ? "flag" :
"other";
interface MeterReading {
meterId: string;
kWh: number;
estimated: boolean;
takenAt: Date;
}
type Schema<T> = { [K in keyof T]: FieldKind<T[K]> };
const schema: Schema<MeterReading> = {
meterId: "text",
kWh: "numeric",
estimated: "flag",
takenAt: "other",
};
console.log(schema);
{
meterId: 'text',
kWh: 'numeric',
estimated: 'flag',
takenAt: 'other'
}
Note the order of the checks: a literal type such as
"E-1001" fits into string, and
Date is not a number, string or Boolean, so it ends in
the last branch.
Distribution over unions
A
distributive conditional type
is a conditional type whose checked type is a bare type parameter,
such as T extends …. When it receives a union, it is
applied to each member separately, and the results are joined into a
new union. ToArray<string | number> therefore
becomes string[] | number[]: an array of only strings
or of only numbers, not a mixed one. To switch distribution off,
wrap both sides in square brackets:
type ToArray<T> = T extends unknown ? T[] : never;
type NotDistributed<T> = [T] extends [unknown] ? T[] : never;
// ToArray<string | number> is string[] | number[]
const ids: ToArray<string | number> = ["E-1001", "E-1002"];
const kWh: ToArray<string | number> = [4.2, 3.1];
// NotDistributed<string | number> is (string | number)[]
const mixed: NotDistributed<string | number> = ["E-1001", 4.2];
console.log(ids.length, kWh.length, mixed.length);
2 2 2
With the distributive version, a mixed array is an error, and the message shows the union the compiler built:
type ToArray<T> = T extends unknown ? T[] : never;
const mixed: ToArray<string | number> = ["E-1001", 4.2];
example.ts(3,7): error TS2322: Type '(string | number)[]' is not assignable to type 'string[] | number[]'.
Distribution is what makes filtering a union possible. The built-in
Exclude returns never for the members to
remove, and a union with never in it simply loses that
member:
type Status = "open" | "in-progress" | "resolved" | "closed";
// The same definition as the built-in Exclude
type MyExclude<T, U> = T extends U ? never : T;
type Active = MyExclude<Status, "resolved" | "closed">;
const s: Active = "closed";
example.ts(7,7): error TS2322: Type '"closed"' is not assignable to type 'Active'.
Extracting types with infer
The
infer keyword
declares a new type variable inside the extends part of
a conditional type. TypeScript fills it in with whatever stands at
that position when the match succeeds, and the variable can be used
in the true branch. It works like a pattern with a placeholder:
T extends (infer Item)[] asks "is T an
array, and if so, of what?"
// Pull a type out of another type with infer.
type ElementOf<T> = T extends (infer Item)[] ? Item : never;
type Unwrap<T> = T extends Promise<infer Value> ? Value : T;
type ResultOf<F> = F extends (...args: any[]) => infer R ? R : never;
async function loadTariff(code: string) {
return { code, pricePerKWh: code === "DAY" ? 0.32 : 0.25 };
}
type Tariff = Unwrap<ResultOf<typeof loadTariff>>;
type Reading = ElementOf<{ meterId: string; kWh: number }[]>;
const night: Tariff = { code: "NIGHT", pricePerKWh: 0.25 };
const r: Reading = { meterId: "E-1001", kWh: 4.2 };
console.log((r.kWh * night.pricePerKWh).toFixed(2));
1.05
loadTariff has no written return type. The type
Tariff is read from the function itself: first the
return type (Promise<{ code: string; pricePerKWh: number }>), then the value inside the promise. When the function changes,
the type follows. The built-in ReturnType and
Awaited do the same, and are what you would use in real
code; infer is for the cases they do not cover.
Template literal types
A template literal type
describes a set of strings with the same backtick syntax as a
JavaScript
template literal, but with types in the placeholders.
`reading_${Unit}` with
Unit = "kWh" | "m3" is the union
"reading_kWh" | "reading_m3". A typo in such a key is
caught, and the compiler suggests the closest match:
type Unit = "kWh" | "m3";
type ReadingKey = `reading_${Unit}`;
const key: ReadingKey = "reading_kwh";
example.ts(4,7): error TS2820: Type '"reading_kwh"' is not assignable to type '"reading_kWh" | "reading_m3"'. Did you mean '"reading_kWh"'?
With more than one union in the template, the result contains every combination, like a multiplication table. Two regions and two kinds of event give four channel names:
type Region = "north" | "south";
type Kind = "outage" | "maintenance";
// Every combination: "north:outage" | "north:maintenance" | ...
type Channel = `${Region}:${Kind}`;
const channels: Channel[] = [
"north:outage",
"north:maintenance",
"south:outage",
"south:maintenance",
];
console.log(channels.length);
4
That is convenient, and also a trap: three unions of ten members each already make a thousand strings. Keep the unions small.
Patterns such as INC-${number}
A placeholder can also hold string or
number. Then the type is not a list of strings but a
pattern: `INC-${number}` accepts
"INC-30412" and rejects a problem record ID:
type TicketId = `INC-${number}`;
function openTicket(id: TicketId): string {
return `Opening ${id}`;
}
console.log(openTicket("INC-30412"));
openTicket("PRB-0042");
example.ts(8,12): error TS2345: Argument of type '"PRB-0042"' is not assignable to parameter of type '`INC-${number}`'.
Such a pattern only checks strings the compiler can see in the code.
A ticket ID typed by a user or received from an API is a plain
string, and you need a check at run time before you may
call it a TicketId. A
type guard with a
regular expression connects the two, and can be stricter than the
type (exactly five digits):
type TicketId = `INC-${number}`;
function isTicketId(text: string): text is TicketId {
return /^INC-\d{5}$/.test(text);
}
for (const input of ["INC-30412", "inc-30412", "INC-3O412"]) {
console.log(input, isTicketId(input) ? "valid" : "rejected");
}
INC-30412 valid inc-30412 rejected INC-3O412 rejected
Changing case and taking strings apart
Four built-in types change the case of string literal types:
Uppercase, Lowercase,
Capitalize (first letter uppercase) and
Uncapitalize (first letter lowercase). They are built
into the compiler; their definition in the standard library just
says intrinsic. Together with infer, a
template literal type can also take a string type apart:
// Take a template literal type apart again with infer.
type SplitChannel<C> = C extends `${infer Region}:${infer Kind}` ? { region: Region; kind: Kind } : never;
type Parsed = SplitChannel<"north:outage">;
const p: Parsed = { region: "north", kind: "outage" };
console.log(p);
{ region: 'north', kind: 'outage' }
// The first character of a string type, taken apart with infer.
type FirstChar<S> = S extends `${infer F}${string}` ? F : never;
const icon: FirstChar<"🔌 E-1001 offline"> = "🔌";
console.log(icon, icon.length, [...icon].length);
🔌 2 1
How the utility types are built
The utility types are not special
compiler features. They are ordinary type definitions in the
standard library file lib.es5.d.ts, and with the tools
on this page you can read them:
// From lib.es5.d.ts, the standard library that ships with TypeScript (comments removed)
type Partial<T> = {
[P in keyof T]?: T[P];
};
type Required<T> = {
[P in keyof T]-?: T[P];
};
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
type Record<K extends keyof any, T> = {
[P in K]: T;
};
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type Uppercase<S extends string> = intrinsic;
-
Partial,RequiredandReadonlyare mapped types that add or remove a modifier. -
PickandRecordare mapped types over a given set of keys instead ofkeyof T. -
ExcludeandExtractare distributive conditional types that filter a union. -
Omitcombines them: pick every key except the excluded ones. -
ParametersandReturnTypeuseinferto read a function type.
keyof any is string | number | symbol,
every type a property key can have. Reading these definitions is a
good exercise: once they make sense, so does most type-level code
you will meet in libraries.
Worked example: typed event handlers
The outage dashboard at Kestrel Energy reacts to a fixed list of
events. Each event needs a handler called
on plus the event name. One mapped type with key
remapping and a template literal type derives the handler object
from the list of event names:
type EventName = "outageReported" | "outageResolved" | "readingReceived";
type Handlers = {
[E in EventName as `on${Capitalize<E>}`]: (id: string) => void;
};
const handlers: Handlers = {
onOutageReported: (id) => console.log(`Page the NOC for ${id}`),
onOutageResolved: (id) => console.log(`Close ${id}`),
onReadingReceived: (id) => console.log(`Store reading for ${id}`),
};
function emit(event: EventName, id: string): void {
const name = `on${event[0].toUpperCase()}${event.slice(1)}` as keyof Handlers;
handlers[name](id);
}
emit("outageReported", "INC-30415");
emit("readingReceived", "E-1001");
Page the NOC for INC-30415 Store reading for E-1001
Add an event name to the union, and every handler object that lacks the new handler fails to compile. A misspelled handler name is caught with a suggestion:
type EventName = "outageReported" | "outageResolved";
type Handlers = {
[E in EventName as `on${Capitalize<E>}`]: (id: string) => void;
};
const handlers: Handlers = {
onOutageReported: (id) => console.log(id),
onOutageResolve: (id) => console.log(id),
};
example.ts(9,3): error TS2561: Object literal may only specify known properties, but 'onOutageResolve' does not exist in type 'Handlers'. Did you mean to write 'onOutageResolved'?
One detail in emit deserves attention: the handler name
is built at run time with ordinary string methods, and
as keyof Handlers tells the compiler to trust that the
result is right. The type-level Capitalize and the
run-time toUpperCase() do the same thing, but the
compiler does not check that they agree. Keep such assertions small,
and in one place.
When to use them, and when not
These tools are powerful, and that is exactly the risk. Some guidelines:
-
Use the built-in utility types first.
Partial<Customer>is clear to every TypeScript developer; a home-madeDeepOptionalExcept<T, K>is not. - Derive types where drift is a real risk: forms and API views of a model, handler maps, configuration keys. That is where a computed type pays for itself.
-
Name intermediate steps. A type alias such as
ActiveorTariffin the examples above makes errors readable, because the compiler shows the name instead of the full expansion. - Expect slower checks and harder messages with deeply nested or recursive types. Large template literal unions and recursive conditional types are a common cause of slow editors; the compiler also stops recursion that goes too deep.
- Remember they are erased. A computed type is as blind to real data as any other type. Data from outside still needs a check at run time, as async code and typing API data shows.