Advanced TypeScript
TypeScript's type system is a language within a language. Once you move past basic annotations, you unlock a toolkit that catches entire classes of bugs at compile time without writing a single runtime check.
This guide takes you from utility types (the most practical starting point) through conditional types, the infer keyword, template literal types, and mapped types. Each concept builds on the last. Work through them in order and you will have covered what separates a mid-level TypeScript developer from a senior one.
Part 1
Utility Types
TypeScript ships a set of generic helpers that transform existing types instead of making you rewrite them. Mastering these eight removes most of the boilerplate from everyday TypeScript code.
8 types
Type
typeof
In a type position, typeof extracts the TypeScript type of any value or variable. This is how you keep your types in sync with your data without duplicating declarations.
The most common use is extracting the type of a config object or a function so you can reference it elsewhere without importing a separate interface.
const config = {
host: "localhost",
port: 5432,
ssl: false,
};
// Extract the type from the value no separate interface needed
type Config = typeof config;
// { host: string; port: number; ssl: boolean }
// Works on functions too
function greet(name: string): string {
return `Hello, ${name}`;
}
type GreetFn = typeof greet;
// (name: string) => string
// Useful when working with enums-as-objects
const Direction = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
type Direction = typeof Direction;
// { Up: "UP"; Down: "DOWN"; Left: "LEFT"; Right: "RIGHT" }
// Manually declaring an interface that mirrors a value
interface Config {
host: string;
port: number;
ssl: boolean;
}
const config = { host: "localhost", port: 5432, ssl: false };
// Add ssl2 to config → have to remember to add it here too
// They drift apart silently
function connect(cfg: Config) { /* ... */ }
// Derive the type from the value stays in sync automatically
const config = { host: "localhost", port: 5432, ssl: false };
type Config = typeof config;
// { host: string; port: number; ssl: boolean }
// Add a field to config → Config updates automatically, no extra work
function connect(cfg: Config) { /* ... */ }
Type
keyof
keyof T produces a union of all the keys of type T as string (or number/symbol) literals. This lets you write functions that accept only valid property names of an object catching typos at compile time instead of runtime.
type User = {
id: number;
name: string;
email: string;
};
type UserKeys = keyof User;
// "id" | "name" | "email"
// Classic use case: a type-safe property getter
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
const name = getProperty(user, "name"); // string ✓
const id = getProperty(user, "id"); // number ✓
// getProperty(user, "age"); // ✗ compile error "age" not in User
// Combine with typeof for a single-source-of-truth approach
const defaults = { theme: "light", language: "en", notifications: true };
type DefaultKeys = keyof typeof defaults;
// "theme" | "language" | "notifications"
// string key no compile-time check on what you pass
function getProperty(obj: object, key: string) {
return (obj as any)[key]; // type assertion needed
}
const user = { id: 1, name: "Alice" };
getProperty(user, "nme"); // typo no error, returns undefined at runtime
// keyof constraint typos and invalid keys fail at compile time
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Alice" };
getProperty(user, "name"); // ✓ returns string
// getProperty(user, "nme"); // ✗ compile error, "nme" not in type
Type
Record
Record<Keys, Value> constructs an object type where every key in Keys maps to Value. Use it instead of a plain index signature when you know the exact set of keys upfront TypeScript will tell you if you miss one.
type Status = "active" | "inactive" | "pending";
// Every status must have a label TypeScript enforces completeness
const statusLabels: Record<Status, string> = {
active: "Active",
inactive: "Inactive",
pending: "Pending review",
// missing a key → compile error
};
// Works great with keyof
type Role = "admin" | "editor" | "viewer";
type Permissions = Record<Role, string[]>;
const permissions: Permissions = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};
// Generic cache map
function makeCache<K extends string, V>(): Record<K, V | undefined> {
return {} as Record<K, V | undefined>;
}
// Index signature accepts any string no exhaustiveness check
type Status = "active" | "inactive" | "pending";
const statusLabels: { [key: string]: string } = {
active: "Active",
// "inactive" and "pending" silently missing no error
};
// statusLabels["inactive"] is undefined at runtime
// Record enforces every key in the union must be present
type Status = "active" | "inactive" | "pending";
const statusLabels: Record<Status, string> = {
active: "Active",
inactive: "Inactive",
pending: "Pending review",
// Removing any key → compile error immediately
};
Type
Pick and Omit
These two are inverses of each other. Pick<T, K> keeps only the listed keys; Omit<T, K> removes them. Both derive a new type from an existing one, so when the source type changes, the derived type stays correct automatically.
type User = {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
};
// Pick keep only what you need
type PublicUser = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }
// Omit drop the sensitive fields
type SafeUser = Omit<User, "password">;
// { id: number; name: string; email: string; createdAt: Date }
// Common pattern: form types derived from model types
type CreateUserInput = Omit<User, "id" | "createdAt">;
// { name: string; email: string; password: string }
// Update form: everything optional except the id
type UpdateUserInput = Pick<User, "id"> & Partial<Omit<User, "id">>;
Rule of thumb: if you know the keys you want, use Pick. If you know the keys you want to exclude, use Omit.
// Manually copying fields drifts when User changes
type User = { id: number; name: string; email: string; password: string };
// If User gains a "role" field, this type stays wrong silently
type PublicUser = {
id: number;
name: string;
email: string;
};
// Derived from User updates automatically when User changes
type User = { id: number; name: string; email: string; password: string };
type PublicUser = Omit<User, "password">;
// { id: number; name: string; email: string }
// Add "role" to User → PublicUser gains it too
Type
Partial
Partial<T> makes every property of T optional. This is the most-reached-for utility type in most codebases it appears in update functions, patch endpoints, form state, and configuration merging.
type UserSettings = {
theme: "light" | "dark";
language: string;
notifications: boolean;
timezone: string;
};
// All fields optional great for PATCH endpoints
type UserSettingsPatch = Partial<UserSettings>;
// { theme?: "light" | "dark"; language?: string; ... }
function updateSettings(current: UserSettings, patch: Partial<UserSettings>): UserSettings {
return { ...current, ...patch };
}
// Config with defaults pattern
function createConfig(overrides?: Partial<UserSettings>): UserSettings {
const defaults: UserSettings = {
theme: "light",
language: "en",
notifications: true,
timezone: "UTC",
};
return { ...defaults, ...overrides };
}
createConfig({ theme: "dark" }); // only override what you need
// Requiring all fields for a partial update callers must send everything
type UserSettings = { theme: string; language: string; notifications: boolean };
function updateSettings(patch: UserSettings) {
// Caller must provide ALL fields even to change one
}
// Just want to change theme? Still must include language and notifications
updateSettings({ theme: "dark", language: "en", notifications: true });
// Partial lets callers send only the fields they want to change
type UserSettings = { theme: string; language: string; notifications: boolean };
function updateSettings(current: UserSettings, patch: Partial<UserSettings>): UserSettings {
return { ...current, ...patch };
}
// Only send what changed the rest stays from current
updateSettings(current, { theme: "dark" });
Type
Required
Required<T> is the opposite of Partialit strips every ? from every property and makes them all required. Use it when a function needs guarantees that all fields have been resolved (e.g. after applying defaults, or after validation).
type RawConfig = {
host?: string;
port?: number;
ssl?: boolean;
};
// After parsing and applying defaults, everything is resolved
type ResolvedConfig = Required<RawConfig>;
// { host: string; port: number; ssl: boolean }
function resolveConfig(raw: RawConfig): ResolvedConfig {
return {
host: raw.host ?? "localhost",
port: raw.port ?? 5432,
ssl: raw.ssl ?? false,
};
}
// Required is also useful to assert completeness after optional chaining
type FormFields = {
name?: string;
email?: string;
};
function submitForm(fields: Required<FormFields>) {
// Here name and email are guaranteed to be strings
console.log(fields.name.toUpperCase());
}
// Optional fields leak through every caller must null-check
type Config = { host?: string; port?: number };
function connect(cfg: Config) {
const host = cfg.host ?? "localhost"; // guard needed
const port = cfg.port ?? 5432; // guard needed
// Null checks scattered everywhere
}
// Resolve once at the boundary, then Required removes all optionality
type RawConfig = { host?: string; port?: number };
type Config = Required<RawConfig>;
function resolveConfig(raw: RawConfig): Config {
return { host: raw.host ?? "localhost", port: raw.port ?? 5432 };
}
function connect(cfg: Config) {
// cfg.host and cfg.port are guaranteed no guards needed
console.log(cfg.host.toUpperCase());
}
Type
NonNullable
NonNullable<T> removes null and undefined from a type. It is most useful when narrowing a union that may have been widened elsewhere, or when you have already checked for null and want the type system to reflect that fact without casting.
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>;
// string
// Useful in arrays after filtering
const rawIds: (number | null | undefined)[] = [1, null, 2, undefined, 3];
const ids: number[] = rawIds.filter((id): id is NonNullable<typeof id> => id != null);
// [1, 2, 3]
// Extracting the non-null return type of a function
function findUser(id: number): User | null {
// ...
return null;
}
type FoundUser = NonNullable<ReturnType<typeof findUser>>;
// User
// Combining with other utilities
type MaybeConfig = Partial<Config> | null | undefined;
type DefiniteConfig = Required<NonNullable<MaybeConfig>>;
// Unsafe cast tells TypeScript to stop checking
const rawIds: (number | null)[] = [1, null, 2, null, 3];
// filter(Boolean) doesn't narrow the type automatically
const ids = rawIds.filter(Boolean) as number[]; // unsafe could hide bugs
// Type predicate with NonNullable narrows correctly, no cast needed
const rawIds: (number | null)[] = [1, null, 2, null, 3];
const ids: number[] = rawIds.filter(
(id): id is NonNullable<typeof id> => id != null
);
// [1, 2, 3] fully type-safe
Example
Real Example Combining Utility Types
Utility types are powerful individually, but they are designed to compose. This example models a typical REST API layer for a user resource one source type, multiple derived types without any duplication.
// Single source of truth
type User = {
id: string;
name: string;
email: string;
password: string;
role: "admin" | "member";
createdAt: Date;
deletedAt: Date | null;
};
// POST /users new user, no id or timestamps yet
type CreateUserDto = Omit<User, "id" | "createdAt" | "deletedAt">;
// PATCH /users/:id any combination of fields except id
type UpdateUserDto = Partial<Omit<User, "id" | "createdAt" | "deletedAt">>;
// GET /users safe to return publicly (no password)
type PublicUser = Omit<User, "password">;
// Internal audit log knows user is non-deleted
type ActiveUser = Required<NonNullable<Omit<User, "deletedAt">>>;
// API handler typed end to end, no type assertions needed
async function updateUser(id: string, dto: UpdateUserDto): Promise<PublicUser> {
const user = await db.users.findById(id);
const updated = { ...user, ...dto };
await db.users.save(updated);
const { password, ...publicUser } = updated;
return publicUser;
}
Notice: the User type is defined once. Every other type derives from it. Change the source and all derived types update automatically.
Part 2
Advanced Type Patterns
Conditional types, infer, template literal types, and mapped types are the four pillars of expert-level TypeScript. Together they let you write types that reason about other types giving you the expressive power to encode complex domain rules at the type level.
6 patterns
Pattern
extends Basics
The extends keyword has two jobs in TypeScript. First, it creates an interface or class hierarchy (inheritance). Second and this is where it gets interesting it acts as a type constraint that says "this type must be assignable to that shape."
// Interface extension classic inheritance
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
const dog: Dog = { name: "Rex", breed: "Labrador" }; // ✓
// extends as a constraint the type must have at least these fields
function logName<T extends { name: string }>(item: T): void {
console.log(item.name);
}
logName({ name: "Alice", role: "admin" }); // ✓ extra fields are fine
// logName({ role: "admin" }); // ✗ missing "name"
// Constraint chains narrow down to very specific shapes
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("hello"); // ✓ string has .length
getLength([1, 2, 3]); // ✓ array has .length
// getLength(42); // ✗ number has no .length
// any no guarantee the property exists, crashes at runtime
function logName(item: any) {
console.log(item.name); // no check that .name is there
}
logName(42); // no compile error, crashes at runtime
logName("hello"); // no compile error, logs undefined
// extends constraint TypeScript verifies the shape before you can call it
function logName<T extends { name: string }>(item: T): void {
console.log(item.name); // guaranteed to exist
}
logName({ name: "Alice", role: "admin" }); // ✓ extra fields fine
// logName(42); // ✗ compile error number has no .name
// logName({}); // ✗ compile error missing required "name"
Pattern
extends with Generics
Combining extends with generic type parameters lets you write flexible functions that stay type-safe without losing information. The return type is automatically narrowed to match the input no casting needed.
// Without extends loses type information
function identity(value: unknown): unknown {
return value;
}
const x = identity("hello"); // unknown useless
// With a generic preserves the exact type
function identity<T>(value: T): T {
return value;
}
const y = identity("hello"); // string ✓
// extends constrains what T can be
function firstElement<T extends readonly unknown[]>(arr: T): T[0] {
return arr[0];
}
const first = firstElement([1, "two", true]); // 1 | "two" | true ✓
// Multiple constraints using intersection
function mergeObjects<A extends object, B extends object>(a: A, b: B): A & B {
return { ...a, ...b };
}
const merged = mergeObjects({ name: "Alice" }, { role: "admin" });
// { name: string; role: string } ✓
// Constrain to keyof for type-safe property access
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const users = [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }];
const names = pluck(users, "name"); // string[] ✓
// pluck(users, "missing"); // ✗ compile error
// unknown return type callers must cast or narrow manually
function firstElement(arr: unknown[]): unknown {
return arr[0];
}
const first = firstElement([1, "two", true]);
first.toString(); // ✗ 'first' is unknown, need to cast: (first as string).toString()
// Generic preserves the exact element type no cast needed
function firstElement<T>(arr: T[]): T {
return arr[0];
}
const first = firstElement([1, "two", true]);
// first is 1 | "two" | true TypeScript knows the type automatically
Pattern
Conditional Types extends with Ternaries
Conditional types let you write type-level if/else logic: T extends U ? TrueType : FalseType. They evaluate at compile time and are one of the most powerful features in TypeScript. Many built-in utility types are implemented with them.
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
// Flatten arrays unwrap one level of nesting
type Flatten<T> = T extends Array<infer Item> ? Item : T;
type C = Flatten<string[]>; // string
type D = Flatten<number>; // number (not an array, so unchanged)
// Distributive conditional types applied to each union member
type ToArray<T> = T extends unknown ? T[] : never;
type E = ToArray<string | number>;
// string[] | number[] (not (string | number)[])
// Non-distributive version wrap in a tuple to prevent distribution
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type F = ToArrayNonDist<string | number>;
// (string | number)[]
// Filtering a union keep only what extends a target
type OnlyStrings<T> = T extends string ? T : never;
type Status = "active" | "inactive" | 42 | boolean;
type StringStatus = OnlyStrings<Status>;
// "active" | "inactive"
// How Exclude is implemented in TypeScript's lib
type MyExclude<T, U> = T extends U ? never : T;
type G = MyExclude<"a" | "b" | "c", "a">;
// "b" | "c"
// Runtime branching TypeScript doesn't narrow the return type
function unwrap(value: string | string[]): string {
if (Array.isArray(value)) return value[0];
return value;
}
// Return type is always "string" callers lose the input relationship
const a = unwrap("hello"); // string fine
const b = unwrap(["a", "b"]); // also string TypeScript doesn't know it came from an array
// Conditional type preserves the input-output relationship at compile time
type Unwrap<T> = T extends string[] ? string : T;
function unwrap<T extends string | string[]>(value: T): Unwrap<T> {
return (Array.isArray(value) ? value[0] : value) as Unwrap<T>;
}
// TypeScript resolves the output based on the input type
const a = unwrap("hello"); // string
const b = unwrap(["a", "b"]); // string correctly derived
Pattern
infer Keyword
infer can only appear inside the true branch of a conditional type. It tells TypeScript: "when this pattern matches, capture the type at this position into a new variable." This is how you extract types from the inside of other types the function return type, the element type of an array, the resolved type of a Promise, and so on.
// Extract the return type of any function
type ReturnType<T extends (...args: unknown[]) => unknown> =
T extends (...args: unknown[]) => infer R ? R : never;
function fetchUser(): Promise<{ id: string; name: string }> {
return Promise.resolve({ id: "1", name: "Alice" });
}
type FetchUserReturn = ReturnType<typeof fetchUser>;
// Promise<{ id: string; name: string }>
// Unwrap a Promise (one level deep)
type Awaited<T> = T extends Promise<infer Resolved> ? Resolved : T;
type Resolved = Awaited<Promise<string>>;
// string
// Extract parameter types as a tuple
type Parameters<T extends (...args: unknown[]) => unknown> =
T extends (...args: infer P) => unknown ? P : never;
function createUser(name: string, role: "admin" | "member", age: number): void {}
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, role: "admin" | "member", age: number]
// Extract the first parameter only
type FirstParam<T extends (...args: unknown[]) => unknown> =
T extends (first: infer F, ...rest: unknown[]) => unknown ? F : never;
type First = FirstParam<typeof createUser>;
// string
// Recursive unwrap unwrap any depth of Promise
type DeepAwaited<T> = T extends Promise<infer Inner> ? DeepAwaited<Inner> : T;
type Deep = DeepAwaited<Promise<Promise<Promise<number>>>>;
// number
ReturnType, Parameters, and Awaited are all built into TypeScript's standard library they are implemented with infer exactly like the examples above.
// Manually annotating return types drifts when the function changes
async function fetchUser() {
return { id: "1", name: "Alice", role: "admin" };
}
// Must remember to update this if fetchUser changes easy to forget
type UserResponse = { id: string; name: string };
// role is missing already out of sync
// infer extracts the return type automatically always in sync
async function fetchUser() {
return { id: "1", name: "Alice", role: "admin" };
}
type UserResponse = Awaited<ReturnType<typeof fetchUser>>;
// { id: string; name: string; role: string }
// Add a field to fetchUser's return → UserResponse gains it automatically
Pattern
Template Literal Types
Template literal types mirror JavaScript template literals but operate at the type level. They let you construct new string literal types by interpolating other string literal types and combine with mapped types to transform all keys of an object at once.
// Basic template literal type
type Greeting = `Hello, ${string}`;
const g: Greeting = "Hello, world"; // ✓
// const g2: Greeting = "Hi there"; // ✗
// Combining unions every combination is generated
type Color = "red" | "green" | "blue";
type Size = "sm" | "md" | "lg";
type ColorSize = `${Color}-${Size}`;
// "red-sm" | "red-md" | "red-lg" | "green-sm" | ... (9 combinations)
// CSS property name generation
type CSSDirection = "top" | "right" | "bottom" | "left";
type CSSPaddingKey = `padding-${CSSDirection}`;
// "padding-top" | "padding-right" | "padding-bottom" | "padding-left"
// Event handler naming convention
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
// Type-safe event system
type EventMap = {
click: MouseEvent;
focus: FocusEvent;
keydown: KeyboardEvent;
};
type OnEventMap = {
[K in keyof EventMap as `on${Capitalize<string & K>}`]: (event: EventMap[K]) => void;
};
// { onClick: (event: MouseEvent) => void;
// onFocus: (event: FocusEvent) => void;
// onKeydown: (event: KeyboardEvent) => void; }
// Extract route params from a path string
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
// Plain string any value accepted, typos pass silently
function on(event: string, handler: () => void) { /* ... */ }
on("clck", handler); // typo no error
on("mouseEnter", handler); // wrong casing no error
on("nonexistent", handler); // completely invalid no error
// Template literal type only valid handler names are accepted
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
function on(event: HandlerName, handler: () => void) { /* ... */ }
on("onClick", handler); // ✓
// on("clck", handler); // ✗ compile error
// on("mouseEnter", handler); // ✗ compile error not in the union
Pattern
Mapped Types Loop Over Object Keys
Mapped types let you iterate over the keys of a type and transform each one changing the value type, adding or removing modifiers like ? or readonly, or even renaming keys entirely using the as clause. This is how all the utility types from Part 1 are actually implemented inside TypeScript.
// The basic shape: [K in keyof T]: ...
// reads: "for each key K in T, the value type is ..."
type User = { id: number; name: string; email: string };
// Make everything optional this IS how Partial<T> works
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make everything readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Transform every value type to a different type
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }
// Rename keys using "as" key remapping
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string; getEmail: () => string }
// Filter keys using "as" with a conditional type (return never to drop the key)
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type Mixed = { id: number; name: string; active: boolean; email: string };
type StringFields = OnlyStrings<Mixed>;
// { name: string; email: string }
// Build a validation schema type from a model
type ValidationRules<T> = {
[K in keyof T]: {
required: boolean;
validate?: (value: T[K]) => string | null;
};
};
type UserValidation = ValidationRules<User>;
// { id: { required: boolean; validate?: (value: number) => string | null };
// name: { required: boolean; validate?: (value: string) => string | null };
// email: { required: boolean; validate?: (value: string) => string | null }; }
Mapped types compose naturally with everything in this guide. The pattern [K in keyof T as ...]: ... is the foundation for building type-level transformations that would otherwise require runtime code or a lot of copy-pasting.
// Manually copying each field breaks every time User changes
type User = { id: number; name: string; email: string };
// Have to update this manually whenever User gains or loses fields
type NullableUser = {
id: number | null;
name: string | null;
email: string | null;
// Add "role" to User → must remember to add it here too
};
// Mapped type handles all keys automatically zero maintenance
type User = { id: number; name: string; email: string };
type Nullable<T> = { [K in keyof T]: T[K] | null };
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }
// Add "role" to User → NullableUser gains "role: string | null" for free