DEV Community

Sajan Acharya
Sajan Acharya

Posted on

Advanced TypeScript: Patterns & Generic Types

Why Generics Matter

Generics are one of TypeScript's most powerful features. They allow you to create reusable components that work with a variety of types while retaining full type safety. Instead of writing separate functions for each type, generics let you write one function that works with many types, reducing code duplication and improving maintainability.

// Without generics - you need multiple functions
function getStringValue(): string { return "hello"; }
function getNumberValue(): number { return 42; }

// With generics - one function for all types
function getValue<T>(): T { /* ... */ }

// More practical example

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
  pagination?: {
    page: number;
    total: number;
  };
}
Enter fullscreen mode Exit fullscreen mode

function handleResponse<T>(response: ApiResponse<T>) {
if (response.status === 200) {
return response.data;
}
throw new Error(response.message);
}

// Usage is fully type-safe
const users = await handleResponse<User[]>(userResponse);
const posts = await handleResponse<Post[]>(postsResponse);

Advanced Generic Constraints

You can restrict what types a generic can accept using constraints:

// Generic must extend object
function objectKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}

// Generic must have specific properties
interface HasId {
id: string;
}

function getById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}

Essential Utility Types

TypeScript provides a set of utility types that transform existing types into new types. These save you from writing repetitive type definitions:

Partial<T>: Makes all properties optional. Useful for update operations where only some fields may be provided.
Required<T>: Makes all properties required. The opposite of Partial.
Pick<T, K>: Constructs a type by picking keys K from T. Useful for selecting specific properties.
Omit<T, K>: Constructs a type by picking all keys from T and then removing K. Great for excluding sensitive fields.
Record<K, T>: Constructs an object type with property keys K and values T. Perfect for creating key-value pairs.
Readonly<T>: Makes all properties readonly, preventing accidental mutations.
ReturnType<T>: Extracts the return type of a function type.
Enter fullscreen mode Exit fullscreen mode

Type Inference vs Explicit Types

TypeScript's type inference is powerful, but sometimes explicit types are necessary for documentation and catching errors early. Here's a practical comparison:

// Type inference - good for simple cases
const numbers = [1, 2, 3]; // TypeScript infers: number[]
const user = { name: "Alice", age: 30 }; // Inferred as object

// Explicit types - better for clarity and documentation
`interface User {
name: string;
age: number;
email: string;
role: 'admin' | 'user';
}

const user: User = {
name: "Alice",
age: 30,
email: "alice@example.com",
role: "admin"
};`

// Function return types should usually be explicit
function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}

Union and Intersection Types

Union types allow a variable to be one of several types, while intersection types combine multiple types:

// Union types - the value can be one of these types

type Status = 'pending' | 'approved' | 'rejected';
type Result = string | number | boolean;

function process(value: string | number) {
  if (typeof value === 'string') {
    return value.toUpperCase();
  }
  return value * 2;
}
Enter fullscreen mode Exit fullscreen mode

// Intersection types - combines all properties of multiple types

interface Admin {
  adminLevel: number;
  canDelete: boolean;
}

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

type AdminUser = Admin & User; // Has all properties of both

const admin: AdminUser = {
  id: "1",
  name: "Alice",
  adminLevel: 5,
  canDelete: true
};
Enter fullscreen mode Exit fullscreen mode

Conditional Types

Conditional types allow you to select different types based on conditions. This enables powerful type transformations:

// Basic conditional type structure: T extends U ? X : Y
type IsString = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>; // false

// Practical example: extracting array element type
type Flatten = T extends Array ? U : T;

type Str = Flatten; // string
type Num = Flatten; // number

// Mapping over object properties
type Getters = {
[K in keyof T as get${Capitalize<string & K>}]: () => T[K]
};

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

type UserGetters = Getters;
// Results in:
// {
// getName: () => string;
// getAge: () => number;
// }

Advanced Pattern: Generic Factory Functions

Factory functions combined with generics create powerful, reusable patterns for creating instances with full type safety:

// Generic factory pattern for creating entities

interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

function createEntity<T extends Entity>(
  data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>
): T {
  return {
    ...data,
    id: crypto.randomUUID(),
    createdAt: new Date(),
    updatedAt: new Date(),
  } as T;
}

interface Product extends Entity {
  name: string;
  price: number;
  stock: number;
}

// Usage is fully type-safe
const newProduct = createEntity<Product>({
  name: "Laptop",
  price: 999.99,
  stock: 5,
});
Enter fullscreen mode Exit fullscreen mode

// TypeScript prevents missing required fields at compile time

Advanced Pattern: Builder Pattern with Generics

The builder pattern combined with generics creates fluent, type-safe APIs:

// Generic builder pattern
class QueryBuilder {
private filters: Array<(item: T) => boolean> = [];
private sortFn: ((a: T, b: T) => number) | null = null;
private limit: number | null = null;

where(predicate: (item: T) => boolean): this {
this.filters.push(predicate);
return this;
}

orderBy(compareFn: (a: T, b: T) => number): this {
this.sortFn = compareFn;
return this;
}

take(n: number): this {
this.limit = n;
return this;
}

execute(items: T[]): T[] {
let result = items.filter(item =>
this.filters.every(filter => filter(item))
);

if (this.sortFn) {
  result.sort(this.sortFn);
}

if (this.limit !== null) {
  result = result.slice(0, this.limit);
}

return result;
Enter fullscreen mode Exit fullscreen mode

}
}

// Usage with type safety
interface Post {
id: number;
title: string;
views: number;
createdDate: Date;
}

const posts: Post[] = [ /* ... */ ];

const topPosts = new QueryBuilder()
.where(post => post.views > 100)
.orderBy((a, b) => b.views - a.views)
.take(5)
.execute(posts);

Advanced Pattern: Discriminated Unions

Discriminated unions (tagged unions) provide a powerful way to handle different data shapes with full type safety:

// Discriminated union pattern
type Result =
| { status: 'success'; data: T }
| { status: 'error'; error: E };

function handleResult(result: Result) {
// Type narrowing based on discriminator
if (result.status === 'success') {
console.log(result.data); // TypeScript knows this is T
} else {
console.log(result.error); // TypeScript knows this is string
}
}

// Real-world API response pattern
type ApiResult =
| { kind: 'loading' }
| { kind: 'success'; payload: T }
| { kind: 'error'; error: Error; code: number };

function processApiResult(result: ApiResult) {
switch (result.kind) {
case 'loading':
return 'Loading...';
case 'success':
return Data: ${JSON.stringify(result.payload)};
case 'error':
return Error ${result.code}: ${result.error.message};
}
}

Advanced Pattern: Type-Safe Events System

Build a type-safe event system using advanced TypeScript patterns:

// Type-safe event emitter
interface EventMap {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'post:created': { postId: string; title: string };
}

type EventKey = keyof EventMap;

class TypeSafeEventEmitter {
private listeners: Map> = new Map();

on(
event: K,
listener: (payload: EventMap[K]) => void
): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
}

emit(event: K, payload: EventMap[K]): void {
const listeners = this.listeners.get(event);
if (listeners) {
listeners.forEach(listener => listener(payload));
}
}
}

// Usage with full type safety
const emitter = new TypeSafeEventEmitter();

emitter.on('user:login', (payload) => {
// TypeScript knows payload has userId and timestamp
console.log(User ${payload.userId} logged in);
});

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

// This would cause a compile error:
// emitter.emit('user:login', { userId: 'user123' }); // Missing timestamp

Keyof and Indexed Access Types

These powerful features allow you to work with object keys and values dynamically while maintaining type safety:

// keyof extracts all keys from a type
interface User {
id: string;
name: string;
email: string;
}

type UserKeys = keyof User; // 'id' | 'name' | 'email'

// Indexed access gets the type of a property
type IdType = User['id']; // string
type NameType = User['name']; // string

// Practical example: type-safe object getter
function getProperty(obj: T, key: K): T[K] {
return obj[key];
}

const user: User = { id: '1', name: 'Alice', email: 'alice@example.com' };
const userId = getProperty(user, 'id'); // string
const userName = getProperty(user, 'name'); // string

// This would fail at compile time:
// const invalid = getProperty(user, 'invalid'); // Error: invalid is not a key

Template Literal Types

TypeScript 4.4 introduced template literal types for creating string unions with composition:

// Creating CSS class names with type safety
type Color = 'red' | 'blue' | 'green';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'primary' | 'secondary';

type ClassName = btn-${Color}-${Size}-${Variant};

// This creates a union of all possible combinations
const validClass: ClassName = 'btn-red-sm-primary'; // OK
const invalidClass: ClassName = 'btn-red-xl-primary'; // Error

// API route pattern matching
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiRoute = /api/${string};
type ApiEndpoint = ${Method} ${ApiRoute};

const endpoint: ApiEndpoint = 'GET /api/users'; // OK
const invalid: ApiEndpoint = 'INVALID /api/users'; // Error

Best Practices for Advanced TypeScript

Use generics for reusability: Write once, use with many types. This reduces code duplication significantly.
Leverage utility types: Don't reinvent the wheel. TypeScript provides Partial, Readonly, Pick, Omit and many more.
Type your dependencies: Always import and use type definitions. Use @types packages for untyped libraries.
Avoid the 'any' type: 'any' defeats the purpose of using TypeScript. If you must use it, add a comment explaining why.
Be explicit with return types: Function return types should be explicit for documentation and error catching.
Use discriminated unions: For complex data, discriminated unions provide better type narrowing than optional properties.
Test your types: Use type-test libraries like tsd or dtslint to verify your type definitions work correctly.
Document complex types: Use JSDoc comments to explain what complex generic types do and why they exist.
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid

Even experienced developers encounter TypeScript gotchas. Here are the most common ones:

// Pitfall 1: Forgetting 'as const' for string literals
const colors = ['red', 'blue', 'green']; // string[]
const colorsConst = ['red', 'blue', 'green'] as const; // readonly ['red', 'blue', 'green']

// Pitfall 2: Over-constraining generics
// Bad: this is too specific

function process<T extends string | number>(value: T): T {
  return value;
}
Enter fullscreen mode Exit fullscreen mode

// Better: let TypeScript infer what it can

function process<T>(value: T): T {
  return value;
}
Enter fullscreen mode Exit fullscreen mode

// Pitfall 3: Assuming type narrowing in callbacks

function processItems<T extends any[]>(items: T) {
  return items.map(item => {
    if (typeof item === 'string') {
      // Even though we narrowed, the return type might not narrow
      return item.toUpperCase();
    }
    return item;
  });
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

While TypeScript is compiled away before runtime, complex type computations can slow down your IDE and build process:

Avoid deeply recursive types: While possible, deeply recursive generic types can slow down type checking significantly.
Use type parameters judiciously: Each generic parameter adds complexity. Only use what you need.
Consider using 'satisfies': TypeScript 4.9 introduced the 'satisfies' keyword, which can help reduce type inference overhead.
Monitor build time: Use --diagnostics flag to identify slow type-checking operations in your code.
Enter fullscreen mode Exit fullscreen mode

Conclusion

Advanced TypeScript mastery requires practice and experimentation. Start with understanding generics, then move to utility types, conditional types, and discriminated unions. These patterns form the foundation of professional TypeScript development. With these tools in your arsenal, you'll write safer, more maintainable code and catch bugs at compile time instead of in production. Keep pushing your TypeScript skills—the investment pays dividends in code quality and developer experience.

Top comments (0)