Back to Blog

Advanced TypeScript Patterns for Enterprise Applications

5 min read
Chivan Visal
TypeScriptSoftware ArchitectureBest PracticesEnterprise

Advanced TypeScript Patterns for Enterprise Applications

As a senior full-stack developer working on enterprise-scale applications, I've learned that TypeScript is more than just "JavaScript with types." In this post, I'll share advanced patterns and practices that have helped me build more maintainable and scalable applications.

1. Discriminated Unions for State Management

One of the most powerful patterns in TypeScript is discriminated unions. They're especially useful for managing complex state:

type RequestState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

// Usage example
function UserProfile() {
  const [userState, setUserState] = useState<RequestState<User>>({ status: 'idle' });

  useEffect(() => {
    async function fetchUser() {
      setUserState({ status: 'loading' });
      try {
        const data = await api.getUser();
        setUserState({ status: 'success', data });
      } catch (error) {
        setUserState({ 
          status: 'error', 
          error: error instanceof Error ? error : new Error('Unknown error') 
        });
      }
    }
    fetchUser();
  }, []);

  // TypeScript ensures we handle all possible states
  switch (userState.status) {
    case 'idle':
      return <div>Not started</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'error':
      return <div>Error: {userState.error.message}</div>;
    case 'success':
      return <div>Welcome, {userState.data.name}!</div>;
  }
}

2. Builder Pattern with Method Chaining

When dealing with complex object construction, the builder pattern with proper typing provides excellent developer experience:

class QueryBuilder<T> {
  private query: Partial<T> = {};

  where<K extends keyof T>(key: K, value: T[K]): this {
    this.query[key] = value;
    return this;
  }

  orderBy<K extends keyof T>(key: K, direction: 'asc' | 'desc'): this {
    // Implementation
    return this;
  }

  build(): Partial<T> {
    return this.query;
  }
}

// Usage with full type safety
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

const query = new QueryBuilder<User>()
  .where('role', 'admin')
  .orderBy('name', 'asc')
  .build();

3. Advanced Type Utilities

Here are some advanced type utilities I've found invaluable in enterprise applications:

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

// Make specific properties required
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;

// Deep partial type
type DeepPartial<T> = T extends object ? {
  [P in keyof T]?: DeepPartial<T[P]>;
} : T;

// Usage example
interface ComplexUser {
  id: number;
  name: string;
  settings: {
    theme: 'light' | 'dark';
    notifications: boolean;
    display: {
      fontSize: number;
      colorScheme: string;
    };
  };
}

// Only 'name' is optional
type UpdateUser = PartialBy<ComplexUser, 'name'>;

// Deeply partial settings
type UserSettings = DeepPartial<ComplexUser['settings']>;

4. Type-Safe Event System

Here's a pattern I use for type-safe event handling:

type EventMap = {
  'user:login': { userId: string; timestamp: number };
  'user:logout': { timestamp: number };
  'error:network': { code: number; message: string };
};

class TypedEventEmitter {
  private listeners: Partial<{
    [K in keyof EventMap]: ((data: EventMap[K]) => void)[];
  }> = {};

  on<K extends keyof EventMap>(event: K, callback: (data: EventMap[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]?.push(callback);
  }

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

// Usage
const events = new TypedEventEmitter();

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

// Type error if event or payload shape doesn't match
events.emit('user:login', { 
  userId: '123', 
  timestamp: Date.now() 
});

5. Dependency Injection with TypeScript

Here's how I implement dependency injection with TypeScript decorators:

// Service decorator
function Service() {
  return function (target: any) {
    Injector.register(target);
  };
}

// Injectable decorator
function Injectable() {
  return function (target: any) {
    // Store metadata about injectable dependencies
    const params = Reflect.getMetadata('design:paramtypes', target) || [];
    Injector.register(target, params);
  };
}

@Service()
class UserService {
  async getUser(id: string): Promise<User> {
    // Implementation
  }
}

@Injectable()
class UserController {
  constructor(private userService: UserService) {}

  async handleGetUser(id: string) {
    return this.userService.getUser(id);
  }
}

Best Practices for Large Applications

  1. Module Augmentation: Extend existing types safely:
declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: 'admin' | 'user';
    } & DefaultSession['user'];
  }
}
  1. Path Aliases: Use TypeScript path aliases for cleaner imports:
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}
  1. Strict Type Checking: Always enable strict mode:
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true
  }
}

Conclusion

These patterns and practices have helped me build more maintainable and scalable applications. They provide strong type safety while keeping the code clean and readable. Remember, TypeScript is not just about adding types; it's about leveraging the type system to write better code.

In my experience working on enterprise applications, these patterns have:

  • Reduced runtime errors by catching issues at compile time
  • Improved code maintainability and refactoring confidence
  • Enhanced developer experience with better IntelliSense support
  • Made code self-documenting through explicit types

Stay tuned for more advanced TypeScript patterns and best practices in future posts!