Beyond Basic TypeScript
If you're only annotating function arguments and return types, you're using maybe 20% of what TypeScript offers. The type system itself is a programming language — one that executes at compile time and can encode complex business logic as type-level constraints.
This article takes you deep into the advanced features that separate TypeScript experts from TypeScript users.
Generics — The Foundation of Reusable Types
Generics let you write code that works with any type while still maintaining type safety. Think of them as type-level function parameters.
Basic Generic Functions
// Without generics — loses type information
function identity(value: unknown): unknown {
return value;
}
// With generics — preserves type information
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // TypeScript infers: number
const str = identity("hello"); // TypeScript infers: stringConstrained Generics
// T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("alice", "bob"); // ✅ strings have .length
longest([1, 2, 3], [4, 5]); // ✅ arrays have .length
longest(10, 20); // ❌ Type Error — numbers have no .lengthGeneric Constraints with keyof
// Safely access object properties — K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Bipin", email: "hi@bipinbaral.com.np" };
getProperty(user, "name"); // ✅ Returns string
getProperty(user, "age"); // ❌ Error: "age" doesn't exist on userMapped Types — Transform Any Type
Mapped types let you create new types by iterating over the keys of another type.
Building Your Own Utility Types
// Readonly<T> — built-in, but let's rebuild it to understand it
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Partial<T> — makes all properties optional
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Required<T> — makes all properties required
type MyRequired<T> = {
[K in keyof T]-?: T[K]; // The - removes optionality
};
// Nullable<T> — allows null on all properties
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};Filtering Properties with Mapped Types
type User = {
id: number;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
};
// Keep only properties whose values are strings
type StringProperties<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type UserStrings = StringProperties<User>;
// Result: { name: string; email: string; }Conditional Types — Type-Level if/else
Conditional types follow the pattern: T extends U ? X : Y
// Extract the element type from an array
type Flatten<T> = T extends Array<infer Item> ? Item : T;
type Num = Flatten<number[]>; // number
type Str = Flatten<string>; // string (not an array, returned as-is)Distributive Conditional Types
When a conditional type is applied to a union, it distributes across each member:
type ToArray<T> = T extends unknown ? T[] : never;
type StrOrNum = ToArray<string | number>;
// Distributes to: string[] | number[]
// NOT: (string | number)[]Practical: NonNullable from scratch
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Clean = MyNonNullable<string | null | undefined | number>;
// Result: string | numberThe infer Keyword — Extract Hidden Types
infer lets you extract a type from within another type during conditional type evaluation.
Extract Function Return Type
type ReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
async function fetchUser(): Promise<{ id: number; name: string }> {
return { id: 1, name: "Bipin" };
}
type UserResult = ReturnType<typeof fetchUser>;
// Result: Promise<{ id: number; name: string }>
// Unwrap the Promise too
type Awaited<T> = T extends Promise<infer U> ? U : T;
type UserData = Awaited<UserResult>;
// Result: { id: number; name: string }Extract Function Parameters
type Parameters<T> = T extends (...args: infer P) => unknown ? P : never;
function createUser(name: string, age: number, role: "admin" | "user") {}
type CreateUserParams = Parameters<typeof createUser>;
// Result: [name: string, age: number, role: "admin" | "user"]Template Literal Types — String Type Magic
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// Result: "onClick" | "onFocus" | "onBlur"
// Build CSS property types
type CSSProperty = "margin" | "padding" | "border";
type CSSDirection = "Top" | "Right" | "Bottom" | "Left";
type CSSLonghand = `${CSSProperty}${CSSDirection}`;
// "marginTop" | "marginRight" | ... | "borderLeft"Parsing String Types
// Extract route params from a URL template string
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"Real-World Pattern: Type-Safe API Client
Combining all of these techniques, we can build a fully type-safe API client:
type ApiRoutes = {
"GET /users": { response: User[]; query: { page?: number } };
"GET /users/:id": { response: User; params: { id: string } };
"POST /users": { response: User; body: CreateUserDto };
"DELETE /users/:id": { response: void; params: { id: string } };
};
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type Route<M extends Method, P extends string> = `${M} ${P}`;
function apiClient<K extends keyof ApiRoutes>(
route: K,
options: Omit<ApiRoutes[K], "response">
): Promise<ApiRoutes[K]["response"]> {
// Implementation...
return fetch("...") as unknown as Promise<ApiRoutes[K]["response"]>;
}
// Fully typed!
const users = await apiClient("GET /users", { query: { page: 2 } });
// ^— User[]
const user = await apiClient("GET /users/:id", { params: { id: "123" } });
// ^— UserKey Takeaways
TypeScript's advanced type system rewards investment. Once you internalize:
- Mapped types transform object type shapes
- Conditional types encode type-level logic
inferextracts types from complex structures- Template literal types manipulate string types
- Generic constraints provide controlled flexibility
You can build types that make entire classes of runtime errors impossible — catching them as compile-time errors before any code runs.



