Advanced TypeScript Features
Explore generics, decorators, utility types, and modules for building scalable, maintainable enterprise applications.
Advanced TypeScript Features
Master advanced TypeScript techniques with free flashcards and spaced repetition practice. This lesson covers conditional types, mapped types, template literal types, utility types, and type guards—essential concepts for building type-safe, scalable applications. TypeScript's advanced features unlock powerful compile-time guarantees that catch bugs before they reach production.
Welcome to Advanced TypeScript 💻
Welcome to the world of advanced TypeScript! If you've mastered the basics—interfaces, generics, and basic type annotations—you're ready to explore the features that separate TypeScript beginners from experts. These advanced techniques enable you to:
- Create highly flexible yet type-safe APIs that adapt to different use cases
- Eliminate entire classes of runtime errors through sophisticated compile-time checks
- Build reusable type utilities that reduce code duplication
- Express complex business logic directly in the type system
Think of advanced TypeScript features as a set of power tools. Just as a master carpenter uses specialized tools beyond the basic hammer and saw, advanced TypeScript developers leverage conditional types, mapped types, and template literals to craft elegant solutions to complex problems.
💡 Pro tip: Don't try to memorize every advanced feature. Focus on understanding the problems they solve, and you'll naturally remember when to apply them!
Core Concepts
1. Conditional Types 🔀
Conditional types allow types to branch based on a condition, similar to ternary operators in JavaScript. They follow the pattern T extends U ? X : Y.
The syntax breakdown:
T extends Uchecks if type T is assignable to type U- If true, the type resolves to
X - If false, the type resolves to
Y
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Real-world use case: Creating APIs that return different types based on input:
type ApiResponse<T> = T extends { error: any }
? { success: false; error: string }
: { success: true; data: T };
type SuccessResult = ApiResponse<{ name: string }>;
// { success: true; data: { name: string } }
type ErrorResult = ApiResponse<{ error: 404 }>;
// { success: false; error: string }
🧠 Memory device: Think "Conditional = Choose" - you're choosing between two type possibilities.
2. Mapped Types 🗺️
Mapped types transform properties of an existing type by iterating over them. They use the in keyword to map over property keys.
Basic pattern:
type Mapped<T> = {
[K in keyof T]: NewType
}
Example - Making all properties optional:
type User = {
id: number;
name: string;
email: string;
};
type PartialUser = {
[K in keyof User]?: User[K];
};
// Result:
// {
// id?: number;
// name?: string;
// email?: string;
// }
Adding modifiers:
readonly: Makes properties read-only?: Makes properties optional-readonly: Removes readonly modifier-?: Removes optional modifier (makes required)
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type ReadonlyUser = {
readonly id: number;
readonly name: string;
};
type EditableUser = Mutable<ReadonlyUser>;
// { id: number; name: string; }
💡 Pro tip: TypeScript's built-in Partial<T>, Required<T>, and Readonly<T> are all implemented using mapped types!
3. Template Literal Types 📝
Template literal types use the same syntax as JavaScript template literals but at the type level. They're incredibly powerful for creating string-based type systems.
Basic usage:
type Greeting = `Hello ${string}`;
const valid: Greeting = "Hello World"; // ✅ Valid
const invalid: Greeting = "Hi World"; // ❌ Error
Combining with union types:
type Color = "red" | "blue" | "green";
type Shade = "light" | "dark";
type ColorShade = `${Shade}-${Color}`;
// Result: "light-red" | "light-blue" | "light-green" |
// "dark-red" | "dark-blue" | "dark-green"
Real-world example - Event system:
type EventName = "click" | "focus" | "blur";
type EventHandler<E extends EventName> = `on${Capitalize<E>}`;
// EventHandler<"click"> → "onClick"
// EventHandler<"focus"> → "onFocus"
🔧 Try this: Create a type for CSS properties like margin-top, padding-left using template literals with "margin" | "padding" and "top" | "left" | "right" | "bottom".
4. Utility Types 🛠️
TypeScript provides built-in utility types that leverage the advanced features we've covered. Understanding how they work helps you create your own.
| Utility Type | Purpose | Example |
|---|---|---|
Partial<T> |
Makes all properties optional | Partial<User> → all fields optional |
Required<T> |
Makes all properties required | Required<Config> → no optional fields |
Pick<T, K> |
Selects specific properties | Pick<User, "id" | "name"> |
Omit<T, K> |
Excludes specific properties | Omit<User, "password"> |
Record<K, T> |
Creates object type with specific keys | Record<string, number> |
Exclude<T, U> |
Excludes types from union | Exclude<"a"|"b"|"c", "a"> → "b"|"c" |
Extract<T, U> |
Extracts matching types | Extract<string|number, number> → number |
NonNullable<T> |
Removes null and undefined | NonNullable<string | null> → string |
ReturnType<T> |
Extracts function return type | ReturnType<() => string> → string |
Parameters<T> |
Extracts function parameters as tuple | Parameters<(a: string) => void> → [string] |
Creating custom utility types:
// Make specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type User = {
id: number;
name: string;
email: string;
};
type UserWithOptionalEmail = PartialBy<User, "email">;
// { id: number; name: string; email?: string; }
5. Type Guards and Narrowing 🛡️
Type guards are runtime checks that narrow types within a code block. They're essential for working with union types safely.
Built-in type guards:
function process(value: string | number) {
if (typeof value === "string") {
// TypeScript knows value is string here
return value.toUpperCase();
} else {
// TypeScript knows value is number here
return value.toFixed(2);
}
}
Custom type guards with is keyword:
interface Dog {
breed: string;
bark(): void;
}
interface Cat {
color: string;
meow(): void;
}
// Custom type guard function
function isDog(animal: Dog | Cat): animal is Dog {
return (animal as Dog).bark !== undefined;
}
function handlePet(pet: Dog | Cat) {
if (isDog(pet)) {
pet.bark(); // ✅ TypeScript knows pet is Dog
} else {
pet.meow(); // ✅ TypeScript knows pet is Cat
}
}
Discriminated unions (tagged unions):
type Success = {
status: "success";
data: any;
};
type Error = {
status: "error";
message: string;
};
type Result = Success | Error;
function handleResult(result: Result) {
// TypeScript narrows based on status property
if (result.status === "success") {
console.log(result.data); // ✅ data exists
} else {
console.log(result.message); // ✅ message exists
}
}
🤔 Did you know? The in operator is also a type guard! if ("property" in object) narrows the type to include that property.
6. Index Access Types 🔑
Index access types let you look up properties on another type.
type User = {
id: number;
name: string;
email: string;
};
type UserId = User["id"]; // number
type UserName = User["name"]; // string
type UserContact = User["name" | "email"]; // string | string → string
Accessing array element types:
type StringArray = string[];
type ArrayElement = StringArray[number]; // string
const users = [{id: 1, name: "Alice"}, {id: 2, name: "Bob"}];
type User = typeof users[number];
// { id: number; name: string; }
7. The infer Keyword 🔍
The infer keyword allows you to extract types within conditional types. It's like a type-level variable declaration.
Basic pattern:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
Here, infer R captures the return type of the function.
Practical examples:
// Extract array element type
type ElementType<T> = T extends (infer U)[] ? U : never;
type Numbers = ElementType<number[]>; // number
type Strings = ElementType<string[]>; // string
// Extract promise resolved type
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type AsyncString = UnwrapPromise<Promise<string>>; // string
type RegularNumber = UnwrapPromise<number>; // number
// Extract first parameter of function
type FirstParameter<T> = T extends (first: infer F, ...args: any[]) => any
? F
: never;
type First = FirstParameter<(a: string, b: number) => void>; // string
🧠 Memory device: infer = **"in"**terrogate and **"fer"**ret out the type.
Detailed Examples
Example 1: Building a Type-Safe Event Emitter 🎯
Let's create an event emitter with complete type safety—no more string typos or wrong payload types!
// Define event map
interface EventMap {
userLogin: { userId: string; timestamp: number };
userLogout: { userId: string };
pageView: { page: string; referrer: string };
}
// Type-safe event emitter
class TypedEventEmitter<Events extends Record<string, any>> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]) {
const handlers = this.listeners[event];
if (handlers) {
handlers.forEach(handler => handler(payload));
}
}
}
// Usage
const emitter = new TypedEventEmitter<EventMap>();
// ✅ Type-safe: correct event and payload
emitter.on("userLogin", (data) => {
console.log(data.userId); // data is typed!
console.log(data.timestamp);
});
// ✅ Autocomplete works for event names
emitter.emit("userLogin", {
userId: "123",
timestamp: Date.now()
});
// ❌ TypeScript catches errors
emitter.emit("userLogin", { userId: "123" }); // Error: missing timestamp
emitter.emit("wrongEvent", {}); // Error: event doesn't exist
What's happening:
Events extends Record<string, any>ensures Events is an object type[K in keyof Events]creates properties for each event nameArray<(payload: Events[K]) => void>ensures handlers match payload type- Generic
K extends keyof Eventsconstrains event names to valid keys
Example 2: Deep Readonly Utility Type 🔒
TypeScript's Readonly<T> only works on the first level. Let's create a recursive version:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
// Test it
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
features: {
enabled: boolean;
};
}
type FrozenConfig = DeepReadonly<Config>;
const config: FrozenConfig = {
database: {
host: "localhost",
port: 5432,
credentials: {
username: "admin",
password: "secret"
}
},
features: {
enabled: true
}
};
// ❌ All nested modifications are caught!
config.database.host = "production"; // Error
config.database.credentials.password = "newpassword"; // Error
config.features.enabled = false; // Error
How it works:
[K in keyof T]maps over all propertiesT[K] extends objectchecks if property is an objectT[K] extends Functionexempts functions (they shouldn't be made readonly)DeepReadonly<T[K]>recursively applies to nested objects- Otherwise, keeps primitive types as-is
Example 3: Path-Based Object Access 🛤️
Create a type that represents valid paths through an object, enabling type-safe deep property access:
// Generate paths like "user.address.city"
type Path<T> = T extends object
? {
[K in keyof T]: K extends string
? T[K] extends object
? K | `${K}.${Path<T[K]>}`
: K
: never;
}[keyof T]
: never;
// Get type at path
type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
? PathValue<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
// Type-safe get function
function getProperty<T, P extends Path<T>>(
obj: T,
path: P
): PathValue<T, P> {
const keys = (path as string).split(".");
let result: any = obj;
for (const key of keys) {
result = result[key];
}
return result;
}
// Usage
interface Data {
user: {
profile: {
name: string;
age: number;
};
settings: {
theme: "light" | "dark";
};
};
}
const data: Data = {
user: {
profile: { name: "Alice", age: 30 },
settings: { theme: "dark" }
}
};
// ✅ Autocomplete and type checking!
const name = getProperty(data, "user.profile.name"); // string
const theme = getProperty(data, "user.settings.theme"); // "light" | "dark"
// ❌ Invalid paths caught
const invalid = getProperty(data, "user.invalid.path"); // Error!
Breaking down the types:
Path<T>recursively generates all valid dot-notation paths- Template literal
K | \\({K}.\){Path<T[K]>}`` creates nested paths PathValue<T, P>recursively navigates to get the type at that pathP extends \\({infer K}.\)`` splits the path at the first dot
Example 4: Builder Pattern with Type State 🏗️
Use the type system to enforce a specific order of method calls:
// State markers
interface HasName { _name: true; }
interface HasEmail { _email: true; }
interface HasAge { _age: true; }
class UserBuilder<State = {}> {
private data: Partial<{
name: string;
email: string;
age: number;
}> = {};
setName(name: string): UserBuilder<State & HasName> {
this.data.name = name;
return this as any;
}
setEmail(email: string): UserBuilder<State & HasEmail> {
this.data.email = email;
return this as any;
}
setAge(age: number): UserBuilder<State & HasAge> {
this.data.age = age;
return this as any;
}
// build() only available when all required fields are set
build(
this: UserBuilder<HasName & HasEmail & HasAge>
): { name: string; email: string; age: number } {
return this.data as any;
}
}
// Usage
const builder = new UserBuilder();
// ❌ Can't build yet
// builder.build(); // Error: missing required fields
const user = builder
.setName("Alice")
.setEmail("alice@example.com")
.setAge(30)
.build(); // ✅ Now build() is available!
console.log(user); // { name: "Alice", email: "alice@example.com", age: 30 }
The clever part:
- Each setter method returns
UserBuilder<State & HasX>, adding to the state build()requiresthis: UserBuilder<HasName & HasEmail & HasAge>- TypeScript ensures you can't call
build()until all markers are present - Compile-time enforcement of the builder pattern!
Common Mistakes ⚠️
Mistake 1: Overusing any in Generic Constraints
❌ Wrong:
function getValue<T extends any>(obj: T, key: string) {
return obj[key]; // Unsafe!
}
✅ Right:
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // Type-safe!
}
Why: any defeats the purpose of TypeScript. Use proper constraints!
Mistake 2: Forgetting extends in Conditional Types
❌ Wrong:
type IsArray<T> = T ? T[] : never; // Syntax error!
✅ Right:
type IsArray<T> = T extends any[] ? true : false;
Why: Conditional types require the extends keyword for the check.
Mistake 3: Circular Type References Without Base Case
❌ Wrong:
type Infinite<T> = {
value: T;
next: Infinite<T>; // Infinite recursion!
};
✅ Right:
type LinkedList<T> = {
value: T;
next: LinkedList<T> | null; // null provides base case
};
Why: Always provide a termination condition for recursive types.
Mistake 4: Modifying Readonly Types in Mapped Types
❌ Wrong:
type Mutable<T> = {
[K in keyof T]: T[K]; // Doesn't remove readonly!
};
✅ Right:
type Mutable<T> = {
-readonly [K in keyof T]: T[K]; // Minus sign removes modifier
};
Why: You must explicitly use -readonly to remove the readonly modifier.
Mistake 5: Type Guards Not Narrowing Properly
❌ Wrong:
function isString(value: any): boolean {
return typeof value === "string"; // Doesn't narrow!
}
✅ Right:
function isString(value: any): value is string {
return typeof value === "string"; // Narrows type!
}
Why: Use value is Type syntax for custom type guards to enable narrowing.
Mistake 6: Not Understanding Distributive Conditional Types
type ToArray<T> = T extends any ? T[] : never;
// This distributes over unions:
type Result = ToArray<string | number>;
// Result is string[] | number[], NOT (string | number)[]
To prevent distribution:
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDistributive<string | number>;
// Result2 is (string | number)[]
Why: Conditional types distribute over naked type parameters in unions. Wrap in brackets to prevent this.
Key Takeaways 🎯
- Conditional types (
T extends U ? X : Y) enable type-level branching logic - Mapped types transform object properties using
[K in keyof T]syntax - Template literal types create string-based type systems with compile-time safety
- Utility types are your friends—learn
Partial,Pick,Omit,Record, and others - Type guards (
value is Type) narrow union types at runtime inferextracts types from complex patterns within conditional types- Index access types (
T[K]) look up property types dynamically - Advanced features work best in combination—layer them to solve complex problems
- Always provide base cases for recursive types to avoid infinite loops
- Use discriminated unions (tagged unions) for maintainable type narrowing
💡 Final wisdom: Advanced TypeScript isn't about using every feature—it's about choosing the right tool for each problem. Start simple, then gradually introduce advanced features as your needs grow.
📚 Further Study
- TypeScript Official Handbook - Advanced Types: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
- Type Challenges Repository: https://github.com/type-challenges/type-challenges
- Matt Pocock's TypeScript Tips: https://www.totaltypescript.com/tutorials
📋 Quick Reference Card
| Feature | Syntax | Use Case |
|---|---|---|
| Conditional Types | T extends U ? X : Y |
Branch based on type condition |
| Mapped Types | [K in keyof T]: NewType |
Transform object properties |
| Template Literals | `${Type1}-${Type2}` |
String-based type combinations |
| Index Access | T[K] |
Look up property type |
| Type Guards | value is Type |
Narrow union types at runtime |
| infer Keyword | T extends Foo<infer U> |
Extract types from patterns |
| Modifiers | -readonly, -? |
Remove readonly/optional |
| Utility - Partial | Partial<T> |
All properties optional |
| Utility - Pick | Pick<T, K> |
Select specific properties |
| Utility - Omit | Omit<T, K> |
Exclude specific properties |
| Utility - Record | Record<K, T> |
Create object with key types |
| Utility - ReturnType | ReturnType<T> |
Extract function return type |
🧠 Quick Memory Aids:
- extends = "is assignable to" check
- keyof = get all property names
- in = iterate over keys
- infer = capture and extract type
- never = impossible type (bottom type)