TypeScript Type-Level Programming: Beyond the Basics
Conditional types, mapped types, template literal types, and the utility types that turn TypeScript into a compile-time language. Practical examples for daily work.
Published on • August 10, 2026
AI Assistant

Most TypeScript is applied types: string, User[], (a: number) => void. Type-level programming is the shift where your types compute — where a type is a function of other types, evaluated by the compiler. Once you can express invariants in the type system, whole classes of runtime bugs stop compiling.
In this post, you will learn the type operators that unlock this: keyof, typeof, indexed access, conditional types, mapped types, and template literal types — and how to compose them into utility types you’ll actually use.
The six type operators at a glance
TypeScript’s handbook organizes type manipulation into six operators:
- Generics — types that take parameters:
Array<T>. keyof— the union of a type’s keys:keyof User→"id" | "name".typeof— the type of a value:typeof config.- Indexed access — pull a property’s type:
User["address"]. - Conditional types — if/else in the type system:
T extends U ? X : Y. - Mapped types — transform each property:
{ [K in keyof T]: T[K] | null }. - Template literal types — strings computed from types:
`get${Capitalize<K>}`.
These compose. That composition is what makes the type system a language.
keyof, typeof, and indexed access: reading shapes
type User = {
id: number;
name: string;
email: string;
createdAt: Date;
};
// keyof: the union of keys
type UserKey = keyof User; // "id" | "name" | "email" | "createdAt"
// indexed access: the type of one property
type UserName = User["name"]; // string
type UserMeta = User["id" | "createdAt"]; // number | Date
type AnyUserField = User[keyof User]; // string | number | Date
typeof works on values — the classic way to derive a type from a constant:
const permissions = ["read", "write", "admin"] as const;
type Permission = (typeof permissions)[number]; // "read" | "write" | "admin"
const env = { port: 3000, debug: false } as const;
type Env = typeof env; // { readonly port: 3000; readonly debug: false }
That (typeof arr)[number] trick is how you turn a runtime config or list into a compile-time union.
Conditional types: if/else in the type system
Conditional types branch on assignability. They’re the closest the type system has to logic:
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<number>; // false
// Distributive conditional types: applies to each union member
type ToString<T> = T extends string ? "str" : "other";
type Result = ToString<"a" | 1 | true>; // "str" | "other" | "other"
A genuinely useful one — extract the element type of an array, or the awaited result of a promise:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<number>>; // number
type B = Unwrap<Promise<Promise<string>>>; // string (it unwraps all the way with recursion)
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type E = ElementOf<string[]>; // string
infer is the key operator here: it lets a conditional type “capture” the type you’re branching on and expose it in the true branch. Awaited<T> and ReturnType<T> are built from exactly this mechanism.
Mapped types: transforming every property
Mapped types iterate over keys and produce a new object type. This is how you make every field optional, nullable, readonly — or how you rewrite a whole shape:
type Nullable<T> = { [K in keyof T]: T[K] | null };
type ReadonlyDeep<T> = { readonly [K in keyof T]: T[K] };
type PartialUser = Partial<User>; // built-in: every field optional
type RequiredUser = Required<User>; // built-in: every field required
type ReadonlyUser = Readonly<User>; // built-in: every field readonly
type PickUserName = Pick<User, "id" | "name">;
type OmitEmail = Omit<User, "email">;
type UserWithRoles = User & { roles: string[] }; // intersection for extension
Mapped types with key remapping let you rename keys — combined with template literal types, this powers API-client generation:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type GettersUser = Getters<User>;
// {
// getId: () => number;
// getName: () => string;
// getEmail: () => string;
// getCreatedAt: () => Date;
// }
The as clause lets you filter and transform keys. You can also drop keys conditionally:
type OnlyStrings<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] };
type S = OnlyStrings<User>; // { name: string; email: string }
Template literal types: strings from types
Template literal types compute string types from other types — useful for typed keys, route parameters, and event names:
type EventName<Prefix extends string> = `${Prefix}:${string}`;
type UserEvent = EventName<"user">; // `user:${string}` — any string starting with "user:"
// Validated route params
type Route = `/users/${string}` | `/posts/${string}`;
type MakeTypedRequest<
T extends Record<string, string>
> = {
[K in keyof T as `${string & K}`]: { path: `/${string & K}`, body: T[K] };
};
They shine in library design — a function that takes a status and a payload and derives the event name and type in one step:
type StatusEvent<T extends string, U> = {
event: `${T}:change`;
value: U;
};
const evt: StatusEvent<"theme", "dark" | "light"> = {
event: "theme:change", // typed as exactly "theme:change"
value: "dark",
};
Putting it all together: a typed event system
Composing everything into something you’d ship:
type EventMap = {
user_created: { id: number };
order_shipped: { orderId: string; tracking: string };
payment_failed: { orderId: string; reason: string };
};
type EventName = keyof EventMap;
// The payload is inferred from the event name — full type safety
type Payload<K extends EventName> = EventMap[K];
function emit<K extends EventName>(name: K, payload: Payload<K>): void {
console.log(name, payload);
}
emit("user_created", { id: 1 }); // ✅
emit("payment_failed", { orderId: "x", reason: "card declined" }); // ✅
// emit("order_shipped", { id: 1 }); // ❌ TS2339: tracking is missing
Now a typo in an event name, or a wrong payload, is a compile error in every call site. This — one discriminated union of events, one generic function, zero runtime validation — is the payoff of type-level programming.
Conclusion & Next Steps
You’ve covered the six type-manipulation pillars — generics, keyof, typeof, indexed access, conditional types with infer, mapped types with key remapping, and template literal types — and composed them into a typed event system. Next steps: build a DeepPartial<T> recursive mapped type, write a Routes<T> type for a framework’s router, and read the source of TypeScript’s built-in Awaited, ReturnType, and Omit — they’re the best reference implementations of everything in this post.
References / Sources
- TypeScript Handbook: Creating Types from Types. https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
- TypeScript Handbook: Conditional Types. https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
- TypeScript Handbook: Mapped Types. https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
- TypeScript Handbook: Template Literal Types. https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
- TypeScript Utility Types reference. https://www.typescriptlang.org/docs/handbook/utility-types.html