Advanced Types & Modules
Master mapped types, conditional types, template literals, and module systems for complex type manipulations.
Advanced Types & Modules in TypeScript
Master TypeScript's advanced type system with free flashcards and spaced repetition practice. This lesson covers conditional types, mapped types, utility types, template literal types, and module organization—essential concepts for building type-safe, scalable applications.
Welcome to Advanced TypeScript 💻
Welcome to the world of advanced TypeScript features! If you've mastered the basics of TypeScript—interfaces, classes, and generics—you're ready to explore the powerful type manipulation capabilities that make TypeScript a true powerhouse for large-scale applications. In this lesson, we'll dive deep into:
- Conditional Types: Create types that change based on conditions
- Mapped Types: Transform existing types systematically
- Template Literal Types: Build string-based types dynamically
- Utility Types: Leverage TypeScript's built-in type helpers
- Module Organization: Structure complex codebases effectively
These advanced features enable you to write more expressive, maintainable, and type-safe code. Let's unlock TypeScript's full potential!
Core Concepts: Advanced Type System 🔧
Conditional Types
Conditional types allow you to create types that depend on a condition, similar to ternary operators in JavaScript. The syntax follows the pattern: T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
💡 Think of it like this: Conditional types are the "if-else" statements of the type system. They let your types make decisions!
The infer Keyword
The infer keyword lets you extract and store types within conditional type expressions:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { name: "Alice", age: 30 };
}
type UserReturnType = ReturnType<typeof getUser>;
// { name: string; age: number; }
Here, infer R captures the return type of the function, making it available in the true branch.
🔍 Real-world use case: Extract promise values automatically:
type Unpromise<T> = T extends Promise<infer U> ? U : T;
type Result = Unpromise<Promise<string>>; // string
type Direct = Unpromise<number>; // number
Mapped Types
Mapped types transform each property in an existing type according to a rule. They use the syntax: { [K in keyof T]: ... }
type ReadonlyVersion<T> = {
readonly [K in keyof T]: T[K];
};
interface User {
name: string;
age: number;
}
type ReadonlyUser = ReadonlyVersion<User>;
// { readonly name: string; readonly age: number; }
Key Remapping with as
TypeScript 4.1+ allows you to remap keys during mapping:
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; }
🧠 Memory device: "MAP" = Modify All Properties systematically.
Template Literal Types
Template literal types build new string literal types using template literal syntax:
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
You can combine multiple string literal types:
type Color = "red" | "blue" | "green";
type Shade = "light" | "dark";
type ColorVariant = `${Shade}-${Color}`;
// "light-red" | "light-blue" | "light-green" |
// "dark-red" | "dark-blue" | "dark-green"
💡 Pro tip: Use template literals to create type-safe CSS-in-JS systems or route definitions!
Intrinsic String Manipulation Types
TypeScript provides built-in utility types for string manipulation:
| Type | Description | Example |
|---|---|---|
Uppercase<T> | Converts to uppercase | "hello" → "HELLO" |
Lowercase<T> | Converts to lowercase | "WORLD" → "world" |
Capitalize<T> | Capitalizes first letter | "user" → "User" |
Uncapitalize<T> | Lowercases first letter | "Name" → "name" |
Utility Types
TypeScript includes many utility types that perform common type transformations:
Partial and Required
interface Config {
host: string;
port: number;
timeout: number;
}
// Make all properties optional
type PartialConfig = Partial<Config>;
// { host?: string; port?: number; timeout?: number; }
// Make all properties required
type RequiredConfig = Required<PartialConfig>;
// { host: string; port: number; timeout: number; }
Pick and Omit
interface User {
id: number;
name: string;
email: string;
password: string;
}
// Select specific properties
type PublicUser = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string; }
// Exclude specific properties
type UserWithoutPassword = Omit<User, "password">;
// { id: number; name: string; email: string; }
Record
type Role = "admin" | "user" | "guest";
type Permissions = Record<Role, string[]>;
const permissions: Permissions = {
admin: ["read", "write", "delete"],
user: ["read", "write"],
guest: ["read"]
};
🔍 Use case: Record<K, V> is perfect for creating dictionary/map types with specific keys.
Extract and Exclude
type T1 = "a" | "b" | "c" | "d";
type T2 = "a" | "c" | "f";
type T3 = Extract<T1, T2>; // "a" | "c"
type T4 = Exclude<T1, T2>; // "b" | "d"
Advanced Module Patterns 📦
Namespace vs Module
Modules (ES6 imports/exports) are the modern approach:
// user.ts
export interface User {
id: number;
name: string;
}
export function createUser(name: string): User {
return { id: Date.now(), name };
}
// main.ts
import { User, createUser } from './user';
Namespaces (formerly "internal modules") organize code within a single file:
namespace Validation {
export interface StringValidator {
isValid(s: string): boolean;
}
export class EmailValidator implements StringValidator {
isValid(s: string): boolean {
return /^[^@]+@[^@]+$/.test(s);
}
}
}
let validator = new Validation.EmailValidator();
⚠️ Modern best practice: Prefer ES6 modules over namespaces unless maintaining legacy code.
Module Augmentation
You can extend existing modules with declaration merging:
// Original module
interface Array<T> {
first(): T | undefined;
}
Array.prototype.first = function() {
return this[0];
};
const arr = [1, 2, 3];
console.log(arr.first()); // 1 (type-safe!)
💡 Pro tip: Use module augmentation to add type definitions to third-party libraries.
Ambient Modules
Ambient modules declare types for JavaScript libraries without TypeScript definitions:
// declarations.d.ts
declare module 'my-untyped-lib' {
export function doSomething(value: string): number;
export const version: string;
}
// Now you can use it with types!
import { doSomething } from 'my-untyped-lib';
Barrel Exports
Create an index.ts file to re-export multiple modules:
// models/index.ts
export * from './user';
export * from './product';
export * from './order';
// Now import from one location
import { User, Product, Order } from './models';
🔍 Organizational benefit: Barrel exports simplify imports and create clear API boundaries.
Detailed Examples 🎯
Example 1: Building a Type-Safe Event System
Let's create an event emitter with complete type safety:
// Define event map
interface EventMap {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string };
'data:update': { id: string; data: any };
}
// Extract event names
type EventName = keyof EventMap;
// Type-safe event emitter
class TypedEventEmitter {
private listeners: {
[K in EventName]?: Array<(payload: EventMap[K]) => void>;
} = {};
on<K extends EventName>(
event: K,
callback: (payload: EventMap[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
}
emit<K extends EventName>(event: K, payload: EventMap[K]): void {
const callbacks = this.listeners[event];
if (callbacks) {
callbacks.forEach(cb => cb(payload));
}
}
}
// Usage with full type safety
const emitter = new TypedEventEmitter();
emitter.on('user:login', (payload) => {
console.log(`User ${payload.userId} logged in`);
// payload is correctly typed as { userId: string; timestamp: number }
});
emitter.emit('user:login', {
userId: '123',
timestamp: Date.now()
}); // ✓ Type-safe!
// TypeScript error: Property 'wrongField' does not exist
// emitter.emit('user:login', { wrongField: true });
Why this works: The mapped type creates a listener object where each key corresponds to an event name, and the value is an array of callbacks expecting the correct payload type.
Example 2: Deep Readonly Type
Create a utility type that makes an object deeply immutable:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
interface Config {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
cert: string;
};
};
features: string[];
}
type ImmutableConfig = DeepReadonly<Config>;
const config: ImmutableConfig = {
server: {
host: 'localhost',
port: 3000,
ssl: {
enabled: true,
cert: '/path/to/cert'
}
},
features: ['auth', 'logging']
};
// All of these produce TypeScript errors:
// config.server.port = 8080;
// config.server.ssl.enabled = false;
// config.features.push('new-feature');
How it works: The type recursively applies readonly to all properties. It checks if a property is an object (excluding functions) and applies DeepReadonly again, creating nested immutability.
Example 3: Discriminated Union Refiner
Build a utility type that narrows discriminated unions:
type Action =
| { type: 'INCREMENT'; payload: number }
| { type: 'DECREMENT'; payload: number }
| { type: 'RESET' }
| { type: 'SET_VALUE'; payload: { value: number; source: string } };
// Extract action by type
type ActionByType<T extends Action['type']> = Extract<
Action,
{ type: T }
>;
type IncrementAction = ActionByType<'INCREMENT'>;
// { type: 'INCREMENT'; payload: number }
type SetValueAction = ActionByType<'SET_VALUE'>;
// { type: 'SET_VALUE'; payload: { value: number; source: string } }
// Create type-safe reducer
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT':
return state + action.payload; // payload is number
case 'DECREMENT':
return state - action.payload; // payload is number
case 'RESET':
return 0; // no payload available
case 'SET_VALUE':
return action.payload.value; // payload has correct structure
default:
return state;
}
}
Pattern explanation: Extract filters the union type to only include actions with the specified type property, giving you perfect autocomplete and type checking.
Example 4: Builder Pattern with Type State
Implement a builder pattern that enforces method call order at compile time:
interface QueryBuilder<
TSelect extends boolean = false,
TWhere extends boolean = false,
TOrderBy extends boolean = false
> {
select<T>(fields: T[]): QueryBuilder<true, TWhere, TOrderBy>;
where(condition: string): QueryBuilder<TSelect, true, TOrderBy>;
orderBy(field: string): QueryBuilder<TSelect, TWhere, true>;
// Only available when all required steps are complete
execute: TSelect extends true
? TWhere extends true
? () => Promise<any[]>
: never
: never;
}
function createQuery(): QueryBuilder<false, false, false> {
return {
select(fields) {
console.log('SELECT', fields);
return this as any;
},
where(condition) {
console.log('WHERE', condition);
return this as any;
},
orderBy(field) {
console.log('ORDER BY', field);
return this as any;
},
execute: undefined as any
};
}
const query = createQuery();
// This works:
const result = query
.select(['name', 'email'])
.where('age > 18')
.execute(); // ✓ Available
// This doesn't compile:
// const invalid = createQuery()
// .select(['name'])
// .execute(); // ✗ Error: where() not called yet
Type-state pattern: The generic parameters track which methods have been called, preventing invalid sequences at compile time.
Common Mistakes ⚠️
Mistake 1: Overusing any in Conditional Types
❌ Wrong approach:
type BadExtract<T> = T extends (...args: any[]) => any ? T : never;
✅ Better approach:
type GoodExtract<T> = T extends (...args: infer P) => infer R
? (...args: P) => R
: never;
Why: Using infer preserves exact parameter and return types instead of losing them to any.
Mistake 2: Forgetting readonly in Mapped Types
❌ Missing immutability:
type Config<T> = {
[K in keyof T]: T[K]; // Properties are still mutable!
};
✅ Enforce immutability:
type Config<T> = {
readonly [K in keyof T]: T[K];
};
Mistake 3: Circular Type References
❌ Infinite recursion:
type JSONValue =
| string
| number
| boolean
| JSONValue[] // This can cause issues
| { [key: string]: JSONValue };
✅ Proper recursive type:
type JSONPrimitive = string | number | boolean | null;
type JSONValue = JSONPrimitive | JSONArray | JSONObject;
interface JSONArray extends Array<JSONValue> {}
interface JSONObject { [key: string]: JSONValue; }
Fix explanation: Using interfaces for recursive structures helps TypeScript handle circularity better.
Mistake 4: Not Understanding Type Distribution
Conditional types distribute over unions:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] (NOT (string | number)[])
💡 To prevent distribution, use square brackets:
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNoDistribute<string | number>;
// (string | number)[]
Mistake 5: Improper Module Organization
❌ Circular dependencies:
// user.ts
import { Order } from './order';
export interface User { orders: Order[]; }
// order.ts
import { User } from './user';
export interface Order { user: User; } // Circular!
✅ Break the cycle:
// types.ts
export interface User { id: string; orders: string[]; }
export interface Order { id: string; userId: string; }
// user.ts
import { User } from './types';
// order.ts
import { Order } from './types';
Key Takeaways 🎓
📋 Quick Reference Card
| Concept | Syntax | Use Case |
|---|---|---|
| Conditional Types | T extends U ? X : Y | Type decisions based on conditions |
| Infer Keyword | T extends (arg: infer P) => any | Extract types from patterns |
| Mapped Types | {[K in keyof T]: ...} | Transform all properties systematically |
| Key Remapping | [K in keyof T as NewK] | Rename keys during mapping |
| Template Literals | `${A}-${B}` | Build string-based types |
| Utility Types | Partial, Pick, Omit, Record | Common type transformations |
| Module Export | export { Type, function } | Share types and values |
| Declaration Merging | interface X { ... } (multiple times) | Extend existing types |
Essential Patterns to Remember
- Conditional types with
inferunlock powerful type extraction capabilities - Mapped types let you transform entire object structures systematically
- Template literal types enable type-safe string manipulation and generation
- Utility types save time—learn them instead of recreating common patterns
- Module organization matters—prefer ES6 modules over namespaces
- Type-state pattern enforces correctness at compile time, not runtime
- Declaration merging extends existing types without modification
🔍 Real-world principle: Advanced types move errors from runtime to compile time, making your code more reliable and maintainable.
📚 Further Study
- TypeScript Handbook - Advanced Types: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html - Official documentation with comprehensive examples
- Type Challenges: https://github.com/type-challenges/type-challenges - Practice advanced TypeScript with real-world challenges
- TypeScript Deep Dive: https://basarat.gitbook.io/typescript/ - Free online book covering advanced patterns and best practices
🎉 Congratulations! You've mastered advanced TypeScript types and modules. These powerful features will help you build more robust, type-safe applications with confidence. Keep practicing with real-world scenarios to internalize these patterns!