You’ve mastered custom type guards and structural narrowing. Now, welcome to the meta-programming level of TypeScript.
When you build large-scale enterprise applications, libraries, or design systems, you often need types that can adapt, transform, and compute new shapes dynamically based on other types. This is where generics with constraints, mapped types, and conditional types come into play.
1. Bounded Polymorphism: Generics with extends
A basic generic <T> allows a function or component to accept any type. But often, T shouldn't be anything—it needs to satisfy a contract. You can constrain T using the extends keyword.
Imagine writing a safe object property getter:
// `K` is constrained to only be keys of `T`
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Gaurav", role: "Admin" };
const name = getProperty(user, "name"); // Type is `string`
// const invalid = getProperty(user, "age"); // ❌ Compile error: Argument of type '"age"' is not assignable to parameter of type '"id" | "name" | "role"'
-
Why it matters: TypeScript catches typos at compile time and gives you exact return type inference (
T[K]) instead of a broad fallback.
2. Mapped Types: Transforming Existing Types
Instead of writing repetitive interfaces, mapped types allow you to iterate over the keys of an existing type to create a new one.
The syntax looks remarkably like an object key loop:
type User = {
id: number;
name: string;
email: string;
};
// Custom utility: Make all properties readonly and optional
type ReadonlyPartial<T> = {
readonly [K in keyof T]?: T[K];
};
type StrictUser = User;
type FlexibleUser = ReadonlyPartial<User>;
// Equivalent to: { readonly id?: number; readonly name?: string; readonly email?: string; }
Advanced Mapping: Key Remapping via as
In newer TypeScript versions, you can even rename or filter keys using as clauses:
// Turn an object type into a set of event listener method names
type EventListeners<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (newValue: T[K]) => void;
};
type UserListeners = EventListeners<User>;
// Results in: { onIdChange: (newValue: number) => void; onNameChange: (newValue: string) => void; ... }
3. Conditional Types: Type-Level Logic
Conditional types act like a ternary operator (condition ? trueResult : falseResult) for your types. They check whether a type extends another type.
type IsString<T> = T extends string ? "Yes" : "No";
type A = IsString<string>; // "Yes"
type B = IsString<number>; // "No"
Distributive Conditional Types
When conditional types act on a generic union type, they automatically distribute over the union members. This is a subtle but immensely powerful mechanism.
// If T is a union, this checks each member individually
type ToArray<T> = T extends any ? T[] : never;
type StrOrNumArray = ToArray<string | number>;
// Evaluates to: string[] | number[] (not (string | number)[])
4. Putting It Together: Building DeepReadonly
Let's combine mapped types, conditional types, and recursion to build a real-world enterprise utility: DeepReadonly<T>. TypeScript’s built-in Readonly<T> only goes one level deep. Nested objects can still be mutated.
type DeepReadonly<T> = T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// Usage:
const config = {
api: {
endpoint: "https://api.example.com",
timeout: 5000,
},
retries: 3,
};
const secureConfig: DeepReadonly<typeof config> = config;
// ❌ Compile Error: Cannot assign to 'endpoint' because it is a read-only property
// secureConfig.api.endpoint = "https://hack.com";
Summary
-
Bounded Generics (
extends) enforce contracts on your generic types. - Mapped Types let you loop over and transform object keys and values programmatically.
- Conditional Types introduce type-level branching and pattern matching.
Top comments (0)