Mastering TypeScript: Advanced Types and Patterns
TypeScript has evolved far beyond simple type annotations. Modern TypeScript offers powerful features that can help you write more robust, maintainable, and expressive code. Let's explore advanced patterns that will elevate your TypeScript skills.
Advanced Type Patterns
Conditional Types
Conditional types allow you to create types that change based on conditions:
type NonNullable<T> = T extends null | undefined ? never : T
type ApiResponse<T> = T extends string
? { message: T }
: T extends number
? { code: T }
: { data: T }
// Usage
type StringResponse = ApiResponse<string> // { message: string }
type NumberResponse = ApiResponse<number> // { code: number }
type ObjectResponse = ApiResponse<User> // { data: User }
Mapped Types
Create new types by transforming existing ones:
type Partial<T> = {
[P in keyof T]?: T[P]
}
type Required<T> = {
[P in keyof T]-?: T[P]
}
// Custom mapped type
type Stringify<T> = {
[K in keyof T]: string
}
interface User {
id: number
name: string
email: string
}
type UserStrings = Stringify<User>
// Result: { id: string; name: string; email: string }
Template Literal Types
Build types using string templates:
type EventName<T extends string> = `on${Capitalize<T>}`
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type APIEndpoint<T extends HTTPMethod> = `${Lowercase<T>} /api/`
// Usage
type ClickHandler = EventName<'click'> // 'onClick'
type SubmitHandler = EventName<'submit'> // 'onSubmit'
type GetEndpoint = APIEndpoint<'GET'> // 'get /api/'
Utility Type Patterns
Deep Readonly
Create a deeply readonly version of any type:
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}
interface Config {
database: {
host: string
port: number
credentials: {
username: string
password: string
}
}
}
type ReadonlyConfig = DeepReadonly<Config>
// All properties and nested properties are readonly
Pick and Omit Combinations
// Extract specific properties and make them optional
type PartialPick<T, K extends keyof T> = Partial<Pick<T, K>>
// Exclude properties and make remaining required
type RequiredOmit<T, K extends keyof T> = Required<Omit<T, K>>
interface User {
id: number
name: string
email: string
password: string
createdAt: Date
}
type UserUpdateData = PartialPick<User, 'name' | 'email'>
// { name?: string; email?: string }
type PublicUser = RequiredOmit<User, 'password'>
// { id: number; name: string; email: string; createdAt: Date }
Advanced Function Patterns
Function Overloads with Generics
interface Repository<T> {
findById(id: string): Promise<T | null>
findById(id: string[]): Promise<T[]>
findById(id: string | string[]): Promise<T | T[] | null>
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null>
async findById(id: string[]): Promise<User[]>
async findById(id: string | string[]): Promise<User | User[] | null> {
if (Array.isArray(id)) {
return this.findMultiple(id)
}
return this.findSingle(id)
}
}
Higher-Order Type Functions
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never
type ParametersOf<T> = T extends (...args: infer P) => any ? P : never
// Create a type that wraps return values
type AsyncReturn<T extends (...args: any[]) => any> = (
...args: ParametersOf<T>
) => Promise<ReturnTypeOf<T>>
function syncFunction(x: number, y: string): boolean {
return x > 0 && y.length > 0
}
type AsyncVersion = AsyncReturn<typeof syncFunction>
// (x: number, y: string) => Promise<boolean>
Advanced Class Patterns
Mixins with TypeScript
type Constructor<T = {}> = new (...args: any[]) => T
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = Date.now()
getAge() {
return Date.now() - this.timestamp
}
}
}
function Activatable<TBase extends Constructor>(Base: TBase) {
return class extends Base {
isActive = false
activate() {
this.isActive = true
}
deactivate() {
this.isActive = false
}
}
}
class User {
constructor(public name: string) {}
}
const TimestampedUser = Timestamped(User)
const ActiveUser = Activatable(TimestampedUser)
const user = new ActiveUser('John')
user.activate()
console.log(user.getAge())
Abstract Factory Pattern
abstract class DatabaseConnection {
abstract connect(): Promise<void>
abstract query(sql: string): Promise<any[]>
abstract close(): Promise<void>
}
class PostgreSQLConnection extends DatabaseConnection {
async connect() {
// PostgreSQL specific connection
}
async query(sql: string) {
// PostgreSQL specific query
return []
}
async close() {
// PostgreSQL specific cleanup
}
}
class MySQLConnection extends DatabaseConnection {
async connect() {
// MySQL specific connection
}
async query(sql: string) {
// MySQL specific query
return []
}
async close() {
// MySQL specific cleanup
}
}
type DatabaseType = 'postgresql' | 'mysql'
class DatabaseFactory {
static create(type: DatabaseType): DatabaseConnection {
switch (type) {
case 'postgresql':
return new PostgreSQLConnection()
case 'mysql':
return new MySQLConnection()
default:
throw new Error(`Unsupported database type: ${type}`)
}
}
}
Type-Safe Event System
interface EventMap {
'user:created': { user: User }
'user:updated': { user: User; changes: Partial<User> }
'user:deleted': { userId: string }
}
class TypedEventEmitter<T extends Record<string, any>> {
private listeners: {
[K in keyof T]?: Array<(data: T[K]) => void>
} = {}
on<K extends keyof T>(event: K, listener: (data: T[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = []
}
this.listeners[event]!.push(listener)
}
emit<K extends keyof T>(event: K, data: T[K]) {
const eventListeners = this.listeners[event]
if (eventListeners) {
eventListeners.forEach((listener) => listener(data))
}
}
}
const eventEmitter = new TypedEventEmitter<EventMap>()
// Type-safe event listening
eventEmitter.on('user:created', (data) => {
// data is correctly typed as { user: User }
console.log('User created:', data.user)
})
// Type-safe event emission
eventEmitter.emit('user:updated', {
user: someUser,
changes: { name: 'New Name' },
})
Best Practices
1. Use Type Guards Effectively
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function isUser(obj: any): obj is User {
return obj && typeof obj.id === 'number' && typeof obj.name === 'string'
}
// Usage
function processData(data: unknown) {
if (isString(data)) {
// TypeScript knows data is string here
return data.toUpperCase()
}
if (isUser(data)) {
// TypeScript knows data is User here
return `Hello, ${data.name}`
}
}
2. Leverage const Assertions
const themes = ['light', 'dark', 'auto'] as const
type Theme = (typeof themes)[number] // 'light' | 'dark' | 'auto'
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
} as const
type Config = typeof config
// {
// readonly apiUrl: 'https://api.example.com'
// readonly timeout: 5000
// readonly retries: 3
// }
3. Use Branded Types for Better Type Safety
type UserId = string & { readonly brand: unique symbol }
type Email = string & { readonly brand: unique symbol }
function createUserId(id: string): UserId {
// Validation logic here
return id as UserId
}
function createEmail(email: string): Email {
// Email validation logic here
return email as Email
}
function sendEmail(userId: UserId, email: Email) {
// Implementation
}
// This prevents mixing up string types
const id = createUserId('123')
const email = createEmail('user@example.com')
sendEmail(id, email) // ✅ Correct
// sendEmail(email, id) // ❌ Type error
Conclusion
Advanced TypeScript patterns enable you to:
- Write more expressive and type-safe code
- Catch errors at compile time rather than runtime
- Create reusable and maintainable abstractions
- Build better APIs with excellent developer experience
These patterns might seem complex at first, but they become invaluable tools as your applications grow in complexity. Start incorporating them gradually, and you'll find your TypeScript code becoming more robust and maintainable.
Remember: the goal isn't to use every advanced feature, but to choose the right patterns that solve real problems in your codebase.