All Articles
TypeScript

TypeScript Advanced Patterns: Generics, Utility Types, and Type Guards

Level up your TypeScript skills with advanced type patterns and real-world examples

16-20 minutes

TypeScript Advanced Patterns: Generics, Utility Types, and Type Guards

TypeScript's type system is incredibly powerful, but many developers only scratch the surface. In this guide, we'll dive deep into advanced TypeScript patterns that will make your code more type-safe, reusable, and maintainable.

Why Advanced TypeScript Matters

Using advanced TypeScript patterns helps you:

  • 🛡️ Catch bugs at compile time instead of runtime
  • 🔄 Write more reusable code with generics
  • 📝 Better IDE support with accurate autocomplete
  • 🎯 Self-documenting code through types
  • Improved refactoring with type safety

Generics: The Foundation

Generics allow you to write flexible, reusable code while maintaining type safety.

Basic Generic Function

// Without generics - not type-safe
function identity(arg: any): any {
  return arg;
}

// With generics - type-safe
function identity<T>(arg: T): T {
  return arg;
}

const num = identity<number>(42);      // number
const str = identity<string>("hello"); // string
const auto = identity(true);           // boolean (inferred)

Generic Constraints

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length);
  return arg;
}

logLength("hello");        // ✅ Works
logLength([1, 2, 3]);      // ✅ Works
logLength({ length: 10 }); // ✅ Works
// logLength(42);          // ❌ Error: number doesn't have length

Generic Classes

class DataStore<T> {
  private data: T[] = [];

  add(item: T): void {
    this.data.push(item);
  }

  get(index: number): T | undefined {
    return this.data[index];
  }

  getAll(): T[] {
    return [...this.data];
  }
}

const numberStore = new DataStore<number>();
numberStore.add(1);
numberStore.add(2);
// numberStore.add("3"); // ❌ Error

const userStore = new DataStore<User>();
userStore.add({ id: 1, name: "John" });

Utility Types: Built-in Type Transformations

TypeScript provides powerful utility types for common type transformations.

Partial

Makes all properties optional:

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

function updateUser(id: number, updates: Partial<User>) {
  // Only update provided fields
}

updateUser(1, { name: "John" });           // ✅ Valid
updateUser(2, { email: "john@email.com" }); // ✅ Valid

Required

Makes all properties required:

interface Config {
  host?: string;
  port?: number;
  timeout?: number;
}

function createConnection(config: Required<Config>) {
  // All properties must be provided
}

createConnection({
  host: "localhost",
  port: 3000,
  timeout: 5000
}); // ✅ Valid

Pick<T, K>

Creates a type with only selected properties:

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

type UserPreview = Pick<User, 'id' | 'name' | 'email'>;

const preview: UserPreview = {
  id: 1,
  name: "John",
  email: "john@email.com"
  // password not needed
};

Omit<T, K>

Creates a type without specified properties:

type UserWithoutPassword = Omit<User, 'password'>;

const publicUser: UserWithoutPassword = {
  id: 1,
  name: "John",
  email: "john@email.com",
  createdAt: new Date()
  // password omitted
};

Record<K, T>

Creates an object type with specific keys and value types:

type Role = 'admin' | 'user' | 'guest';

const permissions: Record<Role, string[]> = {
  admin: ['read', 'write', 'delete'],
  user: ['read', 'write'],
  guest: ['read']
};

Advanced Type Guards

Type guards help TypeScript narrow down types at runtime.

typeof Type Guards

function processValue(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);
  }
}

instanceof Type Guards

class Dog {
  bark() {
    console.log("Woof!");
  }
}

class Cat {
  meow() {
    console.log("Meow!");
  }
}

function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

Custom Type Guards

interface Fish {
  swim: () => void;
}

interface Bird {
  fly: () => void;
}

// Custom type guard
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function move(pet: Fish | Bird) {
  if (isFish(pet)) {
    pet.swim();
  } else {
    pet.fly();
  }
}

Discriminated Unions

interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  sideLength: number;
}

interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number;
}

type Shape = Circle | Square | Rectangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    case 'rectangle':
      return shape.width * shape.height;
  }
}

Conditional Types

Conditional types allow you to create types based on conditions.

Basic Conditional Type

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

Practical Example: Extract Return Type

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
  return { id: 1, name: "John" };
}

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

Mapped Types

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

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

type ReadonlyUser = Readonly<User>;
// {
//   readonly id: number;
//   readonly name: string;
// }

Real-World Examples

API Response Handler

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  return response.json();
}

// Usage
interface User {
  id: number;
  name: string;
}

const userResponse = await fetchData<User>('/api/user/1');
console.log(userResponse.data.name); // Type-safe!

Form Validation

type ValidationRule<T> = {
  [K in keyof T]: (value: T[K]) => string | null;
};

interface LoginForm {
  email: string;
  password: string;
}

const validationRules: ValidationRule<LoginForm> = {
  email: (value) => {
    return value.includes('@') ? null : 'Invalid email';
  },
  password: (value) => {
    return value.length >= 8 ? null : 'Password too short';
  }
};

Event Emitter

type EventMap = {
  'user:login': { userId: number; timestamp: Date };
  'user:logout': { userId: number };
  'post:created': { postId: number; authorId: number };
};

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, callback: (data: T[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  emit<K extends keyof T>(event: K, data: T[K]) {
    this.listeners[event]?.forEach(callback => callback(data));
  }
}

const emitter = new TypedEventEmitter<EventMap>();

emitter.on('user:login', (data) => {
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});

emitter.emit('user:login', {
  userId: 1,
  timestamp: new Date()
});

Best Practices

1. Use Type Inference When Possible

// ❌ Redundant
const numbers: number[] = [1, 2, 3];

// ✅ Better
const numbers = [1, 2, 3]; // TypeScript infers number[]

2. Prefer Interfaces for Objects

// ✅ Good for objects
interface User {
  id: number;
  name: string;
}

// ✅ Good for unions
type Status = 'pending' | 'approved' | 'rejected';

3. Use const Assertions

const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000
} as const;

// config.apiUrl is now 'https://api.example.com' (literal type)
// not just string

4. Avoid any, Use unknown

// ❌ Bad
function process(data: any) {
  return data.value;
}

// ✅ Better
function process(data: unknown) {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return (data as { value: any }).value;
  }
}

Common Pitfalls

1. Over-engineering Types

// ❌ Too complex
type ComplexType<T> = T extends Array<infer U>
  ? U extends object
    ? { [K in keyof U]: U[K] extends Function ? never : U[K] }
    : never
  : never;

// ✅ Keep it simple
type SimpleType<T> = T extends Array<infer U> ? U : never;

2. Ignoring Strict Mode

Always enable strict mode in tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitAny": true
  }
}

Conclusion

Advanced TypeScript patterns unlock the full potential of the type system. By mastering generics, utility types, and type guards, you can write code that is:

  • More maintainable
  • Easier to refactor
  • Self-documenting
  • Less prone to bugs

Start incorporating these patterns into your projects today, and watch your code quality improve!

Resources

Enjoyed this article?

Explore more deep dives into architecture, performance, and modern .NET.

View All Articles