You are viewing a preview of this lesson. Sign in to start learning
Back to TypeScript

Decorators & Utility Types

Apply metadata and behavior modifications using decorators, and leverage built-in utility types for type transformations.

Decorators & Utility Types in TypeScript

Master TypeScript's advanced metaprogramming features with free flashcards and spaced repetition practice. This lesson covers decorators for runtime behavior modification, utility types for type transformations, and reflection metadataβ€”essential concepts for building sophisticated TypeScript applications with clean, maintainable code.

Welcome to Advanced TypeScript πŸ’»

Welcome to one of TypeScript's most powerful feature sets! Decorators allow you to modify class behavior at design time, while utility types provide elegant ways to transform existing types. Together, these features enable you to write more expressive, maintainable code.

🎯 What You'll Learn:

  • How decorators work and when to use them
  • Built-in utility types for type manipulation
  • Creating custom utility types
  • Reflection and metadata APIs
  • Real-world patterns and best practices

πŸ’‘ Prerequisites: You should be comfortable with TypeScript basics, including classes, interfaces, generics, and type inference.


Core Concept 1: Understanding Decorators 🎨

Decorators are special declarations attached to classes, methods, properties, or parameters that modify their behavior. Think of them as "annotations" that add functionality without changing the original code.

How Decorators Work

A decorator is simply a function that receives information about the decorated element and can modify it:

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed
class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
}

Decorator Syntax

Decorator TypeSyntaxUse Case
Class@decoratorModify or seal classes
Method@decoratorIntercept method calls
Property@decoratorInitialize or validate properties
Parameter@decoratorMark parameters for injection
Accessor@decoratorModify getters/setters

⚠️ Important: Enable "experimentalDecorators": true in your tsconfig.json to use decorators!

Decorator Execution Order

Decorators are applied in a specific order:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   DECORATOR EXECUTION ORDER            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                        β”‚
β”‚  1️⃣ Instance Members (top to bottom)  β”‚
β”‚     β€’ Parameter decorators             β”‚
β”‚     β€’ Method/Accessor/Property         β”‚
β”‚                                        β”‚
β”‚  2️⃣ Static Members (top to bottom)    β”‚
β”‚     β€’ Parameter decorators             β”‚
β”‚     β€’ Method/Accessor/Property         β”‚
β”‚                                        β”‚
β”‚  3️⃣ Constructor Parameters             β”‚
β”‚                                        β”‚
β”‚  4️⃣ Class Decorator (outermost last)   β”‚
β”‚                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Method Decorators

Method decorators receive three parameters:

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  
  descriptor.value = function(...args: any[]) {
    console.log(`Calling ${propertyKey} with:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`Result:`, result);
    return result;
  };
  
  return descriptor;
}

class Calculator {
  @log
  add(a: number, b: number): number {
    return a + b;
  }
}

πŸ’‘ Tip: Method decorators are perfect for logging, performance monitoring, and validation!


Core Concept 2: Property & Class Decorators πŸ—οΈ

Property Decorators

Property decorators don't receive a property descriptor but can observe that a property has been declared:

function readonly(target: any, propertyKey: string) {
  const descriptor: PropertyDescriptor = {
    writable: false,
    configurable: false
  };
  
  Object.defineProperty(target, propertyKey, descriptor);
}

class Person {
  @readonly
  id: number = 12345;
}

Class Decorators with Factories

Decorator factories let you customize decorator behavior:

function Component(config: { selector: string }) {
  return function<T extends { new(...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
      selector = config.selector;
      
      render() {
        console.log(`Rendering component: ${this.selector}`);
      }
    };
  };
}

@Component({ selector: 'app-user' })
class UserComponent {
  name = 'John';
}

πŸ”§ Try this: The factory pattern above is exactly how Angular's @Component decorator works!

Parameter Decorators

Parameter decorators mark parameters for special handling:

function inject(target: any, propertyKey: string | symbol, parameterIndex: number) {
  const existingParameters = Reflect.getMetadata('inject', target, propertyKey) || [];
  existingParameters.push(parameterIndex);
  Reflect.defineMetadata('inject', existingParameters, target, propertyKey);
}

class UserService {
  constructor(@inject private db: Database) {}
}

Core Concept 3: Built-in Utility Types πŸ”§

TypeScript provides powerful utility types that transform existing types. These are built into the language and don't require imports.

Partial

Makes all properties optional:

interface User {
  id: number;
  name: string;
  email: string;
}

// All properties become optional
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; }

function updateUser(id: number, updates: Partial<User>) {
  // Can update just name, just email, or both
}

Required

Makes all properties required (opposite of Partial):

interface Config {
  host?: string;
  port?: number;
  ssl?: boolean;
}

type RequiredConfig = Required<Config>;
// { host: string; port: number; ssl: boolean; }

Readonly

Makes all properties readonly:

interface Point {
  x: number;
  y: number;
}

const point: Readonly<Point> = { x: 10, y: 20 };
// point.x = 5; // Error: Cannot assign to 'x' because it is a read-only property

Pick<T, K>

Selects specific properties from a type:

interface Article {
  id: number;
  title: string;
  content: string;
  author: string;
  createdAt: Date;
}

type ArticlePreview = Pick<Article, 'id' | 'title' | 'author'>;
// { id: number; title: string; author: string; }

Omit<T, K>

Removes specific properties from a type:

type ArticleWithoutContent = Omit<Article, 'content'>;
// { id: number; title: string; author: string; createdAt: Date; }

πŸ“‹ Common Utility Types Quick Reference

Partial<T>All properties optional
Required<T>All properties required
Readonly<T>All properties readonly
Pick<T, K>Select specific properties
Omit<T, K>Remove specific properties
Record<K, T>Create object type with keys K and values T

Core Concept 4: Advanced Utility Types πŸš€

Record<K, T>

Creates an object type with keys of type K and values of type T:

type Role = 'admin' | 'user' | 'guest';
type Permissions = Record<Role, string[]>;

const permissions: Permissions = {
  admin: ['read', 'write', 'delete'],
  user: ['read', 'write'],
  guest: ['read']
};

Exclude<T, U> & Extract<T, U>

Filter union types:

type Status = 'pending' | 'active' | 'suspended' | 'deleted';

// Remove types
type ActiveStatus = Exclude<Status, 'suspended' | 'deleted'>;
// 'pending' | 'active'

// Keep only specific types
type InactiveStatus = Extract<Status, 'suspended' | 'deleted'>;
// 'suspended' | 'deleted'

NonNullable

Removes null and undefined:

type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>;
// string

ReturnType & Parameters

Extract function signature information:

function createUser(name: string, age: number) {
  return { name, age, id: Math.random() };
}

type User = ReturnType<typeof createUser>;
// { name: string; age: number; id: number; }

type CreateUserParams = Parameters<typeof createUser>;
// [name: string, age: number]

Awaited

Unwraps Promise types:

type Response = Promise<{ data: string }>;
type UnwrappedResponse = Awaited<Response>;
// { data: string }

// Works with nested Promises
type Nested = Promise<Promise<number>>;
type Unwrapped = Awaited<Nested>;
// number

Example 1: Creating a Validation Decorator πŸ›‘οΈ

Let's build a practical decorator for property validation:

function MinLength(min: number) {
  return function (target: any, propertyKey: string) {
    let value: string;
    
    const getter = () => value;
    const setter = (newVal: string) => {
      if (newVal.length < min) {
        throw new Error(`${propertyKey} must be at least ${min} characters`);
      }
      value = newVal;
    };
    
    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true
    });
  };
}

class User {
  @MinLength(3)
  username!: string;
  
  @MinLength(8)
  password!: string;
}

const user = new User();
user.username = "ab"; // Error: username must be at least 3 characters
user.username = "alice"; // OK

Why this works:

  1. MinLength is a decorator factory that accepts configuration
  2. It replaces the property with a getter/setter pair
  3. The setter validates the value before assignment
  4. Validation logic is reusable across properties

πŸ’‘ Real-world use: Libraries like class-validator use this pattern extensively!


Example 2: Method Timing Decorator ⏱️

A decorator to measure method execution time:

function Timing(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  
  descriptor.value = async function (...args: any[]) {
    const start = performance.now();
    
    try {
      const result = await originalMethod.apply(this, args);
      return result;
    } finally {
      const end = performance.now();
      console.log(`${propertyKey} took ${(end - start).toFixed(2)}ms`);
    }
  };
  
  return descriptor;
}

class DataService {
  @Timing
  async fetchUsers(): Promise<User[]> {
    const response = await fetch('/api/users');
    return response.json();
  }
  
  @Timing
  async processLargeDataset(data: number[]): Promise<number> {
    return data.reduce((sum, num) => sum + num, 0);
  }
}

// Output when methods are called:
// fetchUsers took 234.56ms
// processLargeDataset took 12.34ms

Key features:

  • Works with async methods using await
  • Uses finally to ensure timing happens even if method throws
  • Preserves the original method's return value
  • Non-invasiveβ€”no changes to method code

Example 3: Building Custom Utility Types πŸ”¨

Create your own utility types for common patterns:

// Make specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

// Make only 'description' optional
type ProductInput = PartialBy<Product, 'description'>;
// { id: number; name: string; price: number; description?: string; }

// Make nested properties optional
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface Config {
  server: {
    host: string;
    port: number;
    ssl: {
      enabled: boolean;
      cert: string;
    };
  };
}

const partialConfig: DeepPartial<Config> = {
  server: {
    ssl: {
      enabled: true
      // 'cert' is optional due to DeepPartial
    }
  }
};

// Create a type-safe builder pattern
type Builder<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => Builder<T>;
} & { build(): T };

type UserBuilder = Builder<User>;
// {
//   setId: (value: number) => UserBuilder;
//   setName: (value: string) => UserBuilder;
//   setEmail: (value: string) => UserBuilder;
//   build(): User;
// }

πŸ”§ Try this: Combine multiple utility types to create complex transformations!


Example 4: Decorator Composition & Metadata 🎭

Combine multiple decorators with reflection metadata:

import 'reflect-metadata';

// Route decorator
function Route(path: string) {
  return function (target: any, propertyKey: string) {
    Reflect.defineMetadata('route:path', path, target, propertyKey);
  };
}

// HTTP method decorator
function HttpGet(target: any, propertyKey: string) {
  Reflect.defineMetadata('route:method', 'GET', target, propertyKey);
}

function HttpPost(target: any, propertyKey: string) {
  Reflect.defineMetadata('route:method', 'POST', target, propertyKey);
}

// Validate decorator
function Validate(schema: any) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    
    descriptor.value = function (...args: any[]) {
      // Validation logic here
      console.log('Validating with schema:', schema);
      return originalMethod.apply(this, args);
    };
  };
}

class UserController {
  @Route('/users')
  @HttpGet
  getUsers() {
    return [{ id: 1, name: 'Alice' }];
  }
  
  @Route('/users')
  @HttpPost
  @Validate({ name: 'string', email: 'string' })
  createUser(data: any) {
    return { id: 2, ...data };
  }
}

// Extract metadata for routing
function getRouteInfo(controller: any, methodName: string) {
  const path = Reflect.getMetadata('route:path', controller.prototype, methodName);
  const method = Reflect.getMetadata('route:method', controller.prototype, methodName);
  return { path, method };
}

console.log(getRouteInfo(UserController, 'getUsers'));
// { path: '/users', method: 'GET' }

Pattern breakdown:

  • Multiple decorators stack on a single method
  • reflect-metadata stores decorator information
  • Metadata can be read later for framework setup
  • This pattern powers NestJS and other frameworks

Common Mistakes & How to Avoid Them ⚠️

Mistake 1: Forgetting Decorator Configuration

❌ Wrong:

// Decorators won't work!
class MyClass {
  @log
  method() {}
}

βœ… Correct:

// tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Mistake 2: Losing this Context

❌ Wrong:

function log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    // 'this' context lost!
    return original(args);
  };
}

βœ… Correct:

function log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    // Preserve 'this' with apply
    return original.apply(this, args);
  };
}

Mistake 3: Overusing Utility Types

❌ Wrong:

// Too complex, hard to read
type ComplexType = Partial<Pick<Omit<Required<User>, 'id'>, 'name' | 'email'>>;

βœ… Correct:

// Break into steps
type RequiredUser = Required<User>;
type WithoutId = Omit<RequiredUser, 'id'>;
type NameAndEmail = Pick<WithoutId, 'name' | 'email'>;
type FinalType = Partial<NameAndEmail>;

// Or create a custom utility
type OptionalNameEmail = {
  name?: string;
  email?: string;
};

Mistake 4: Mutating Descriptors Incorrectly

❌ Wrong:

function readonly(target: any, key: string, descriptor: PropertyDescriptor) {
  descriptor.writable = false;
  // Missing return!
}

βœ… Correct:

function readonly(target: any, key: string, descriptor: PropertyDescriptor) {
  descriptor.writable = false;
  return descriptor; // Always return
}

Mistake 5: Wrong Utility Type Choice

❌ Wrong:

// Using Partial when you need specific optional properties
type UpdateUser = Partial<User>;
// All properties become optional, even 'id'

βœ… Correct:

// Use Omit + Partial for precise control
type UpdateUser = Partial<Omit<User, 'id'>> & Pick<User, 'id'>;
// Only user data is optional, 'id' stays required

🧠 Memory aid: "DAR" - Define, Apply, Return - the three steps of every decorator!


Key Takeaways 🎯

πŸ“‹ Decorators & Utility Types Cheat Sheet

Decorators:

  • Enable with "experimentalDecorators": true
  • Execute order: instance β†’ static β†’ constructor β†’ class
  • Always preserve this context with apply()
  • Use factories for configurable decorators
  • Great for: logging, validation, metadata, AOP

Essential Utility Types:

TypePurposeExample
Partial<T>Optional propertiesUpdate operations
Required<T>Required propertiesStrict validation
Readonly<T>Immutable objectsConfig objects
Pick<T,K>Select propertiesAPI responses
Omit<T,K>Remove propertiesForm inputs
Record<K,T>Map-like objectsDictionaries
ReturnType<T>Function returnsType inference

Best Practices:

  • Keep decorators focused and composable
  • Combine utility types for complex transformations
  • Document custom utility types clearly
  • Test decorator behavior thoroughly
  • Use metadata for framework-level features

πŸ“š Further Study

Official Documentation:

Advanced Resources:

πŸŽ‰ Congratulations! You now have the tools to write more elegant, maintainable TypeScript code using decorators and utility types. Practice combining these features to build powerful abstractions!

πŸ’‘ Next steps: Explore how frameworks like NestJS and TypeORM use these patterns, then try building your own decorator-based library!