[SAMPLE] TypeScript Type System Deep Dive
TypeScript Type System Deep Dive
TypeScript's type system is one of the most powerful among mainstream programming languages. This guide explores advanced type features that enable you to write safer, more expressive code.
Beyond Basic Types
If you already understand string, number, boolean, interfaces, and simple generics, you are ready for this deep dive. We will cover the type-level programming features that distinguish TypeScript experts from beginners.
Generics: The Foundation
Generics let you write functions and types that work with any type while preserving type information through the call.
Basic Generic Function
function first<T>(array: T[]): T | undefined {
return array[0];
}
const num = first([1, 2, 3]); // number | undefined
const str = first(["a", "b", "c"]); // string | undefinedConstrained Generics
Use extends to restrict what types a generic can accept:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // string
getProperty(user, "age"); // number
getProperty(user, "email"); // Error: "email" is not assignableGeneric Defaults
type ApiResponse<T = unknown> = {
data: T;
status: number;
message: string;
};
// T defaults to unknown when not specified
const response: ApiResponse = { data: null, status: 200, message: "OK" };Utility Types
TypeScript ships with several built-in utility types. Understanding how they work internally deepens your grasp of the type system.
Essential Utility Types
// Partial<T> - all properties optional
type Partial<T> = { [P in keyof T]?: T[P] };
// Required<T> - all properties required
type Required<T> = { [P in keyof T]-?: T[P] };
// Readonly<T> - all properties readonly
type Readonly<T> = { readonly [P in keyof T]: T[P] };
// Pick<T, K> - select specific properties
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
// Omit<T, K> - exclude specific properties
type Omit<T, K extends keyof string | number | symbol> = Pick<T, Exclude<keyof T, K>>;
// Record<K, V> - construct object type
type Record<K extends keyof any, V> = { [P in K]: V };Practical Example
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
// For creating a user (no id or createdAt needed)
type CreateUserInput = Omit<User, "id" | "createdAt">;
// For updating a user (all fields optional except id)
type UpdateUserInput = Pick<User, "id"> & Partial<Omit<User, "id">>;Conditional Types
Conditional types let you express type-level if/else logic:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // falseThe infer Keyword
infer extracts types from within conditional type patterns:
// Extract return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Extract element type of an array
type ElementType<T> = T extends (infer E)[] ? E : never;
type Nums = ElementType<number[]>; // number
type Strs = ElementType<string[]>; // string
// Extract promise value
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type Result = Awaited<Promise<Promise<string>>>; // stringDistributive Conditional Types
When a conditional type acts on a union, it distributes over each member:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// = (string extends any ? string[] : never) | (number extends any ? number[] : never)
// = string[] | number[]
// To prevent distribution, wrap in tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// = (string | number)[]Template Literal Types
TypeScript can manipulate string types at the type level:
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<"click">; // "onClick"
type ChangeEvent = EventName<"change">; // "onChange"
// Generate all possible combinations
type Color = "red" | "blue";
type Size = "sm" | "lg";
type ClassName = `${Color}-${Size}`;
// = "red-sm" | "red-lg" | "blue-sm" | "blue-lg"Parsing Strings
// Extract parts of a string type
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// = "userId" | "postId"Mapped Types
Transform existing types by iterating over their keys:
// Make all properties nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };
// Make all properties into getter functions
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }Key Remapping with as
// Remove readonly modifier
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
// Filter keys by value type
type StringKeys<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Mixed {
name: string;
age: number;
email: string;
}
type OnlyStrings = StringKeys<Mixed>;
// { name: string; email: string }Branded Types
TypeScript's structural type system means two types with the same shape are interchangeable. Branded types prevent this:
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId): void { /* ... */ }
function getPost(id: PostId): void { /* ... */ }
const userId = createUserId("user-123");
const postId = "post-456" as PostId;
getUser(userId); // OK
getUser(postId); // Error: PostId is not assignable to UserIdThis pattern is especially useful for IDs, validated strings, and domain-specific values.
Discriminated Unions
Use a common literal property to narrow union types:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}Exhaustiveness Checking
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
return assertNever(shape); // Compile error if a case is missing
}
}Type-Level Programming Patterns
Builder Pattern with Types
type QueryBuilder<Selected extends string = never> = {
select<K extends string>(column: K): QueryBuilder<Selected | K>;
where(condition: string): QueryBuilder<Selected>;
execute(): Promise<Record<Selected, unknown>[]>;
};Recursive Types
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
type NestedObj = {
a: { b: { c: string } };
};
type ReadonlyNested = DeepReadonly<NestedObj>;
// All levels are readonlyType-Safe Event Emitter
type EventMap = {
click: { x: number; y: number };
change: { value: string };
submit: { data: FormData };
};
class TypedEmitter<Events extends Record<string, any>> {
on<K extends keyof Events>(
event: K,
handler: (payload: Events[K]) => void
): void { /* ... */ }
emit<K extends keyof Events>(
event: K,
payload: Events[K]
): void { /* ... */ }
}
const emitter = new TypedEmitter<EventMap>();
emitter.on("click", ({ x, y }) => { /* x and y are typed */ });
emitter.emit("change", { value: "hello" }); // OK
emitter.emit("change", { wrong: true }); // ErrorPractical Tips
- Start with simple types and refine: Do not over-engineer types upfront
- Use
satisfiesfor validation without widening:const config = { ... } satisfies Config - Prefer
unknownoverany: Forces explicit narrowing - Use
as constfor literal types:const routes = ["home", "about"] as const - Avoid excessive type gymnastics: If a type is unreadable, simplify the design
- Test complex types: Use type assertions in tests to verify type behavior
Conclusion
TypeScript's type system is a language within a language. Mastering generics, conditional types, mapped types, and template literals unlocks the ability to create APIs that are both flexible and type-safe. The goal is not cleverness but clarity -- use advanced types to make invalid states unrepresentable and correct usage obvious.