Interactive TypeScript Workshop Exercises - CLAUDE.md

Exercise Structure & Guidelines

How to Use These Exercises

  1. Each exercise has a Problem, Starter Code, and Hints
  2. Try to solve without looking at hints first
  3. Run tests to verify your solution
  4. Compare with the provided solution
  5. Experiment with variations

Setting Up Your Environment

# Initialize exercise environment
npm init -y
npm install -D typescript @types/node jest @types/jest ts-jest
npm install -D @typescript-eslint/parser @typescript-eslint/eslint-plugin
 
# TypeScript config for exercises
cat > tsconfig.json << EOF
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}
EOF
 
# Jest config
cat > jest.config.js << EOF
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/*.test.ts'],
};
EOF

Exercise 1: Generic Type Constraints

Problem

Create a type-safe configuration system that:

  • Validates configuration values
  • Provides auto-completion
  • Prevents invalid configurations
  • Supports nested objects

Starter Code

// src/exercise1.ts
type ConfigSchema = {
  // TODO: Define your schema type
};
 
class ConfigBuilder<T extends ConfigSchema> {
  // TODO: Implement the builder
  
  set<K extends keyof T>(key: K, value: T[K]): this {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  get<K extends keyof T>(key: K): T[K] | undefined {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  validate(): boolean {
    // TODO: Implement validation
    throw new Error('Not implemented');
  }
  
  build(): Readonly<T> {
    // TODO: Return validated config
    throw new Error('Not implemented');
  }
}
 
// Usage example (should work after implementation):
interface AppConfig {
  server: {
    port: number;
    host: string;
    ssl: boolean;
  };
  database: {
    url: string;
    poolSize: number;
  };
  features: {
    authentication: boolean;
    rateLimit: {
      enabled: boolean;
      maxRequests: number;
    };
  };
}
 
const config = new ConfigBuilder<AppConfig>()
  .set('server', { port: 3000, host: 'localhost', ssl: false })
  .set('database', { url: 'postgres://localhost', poolSize: 10 })
  .set('features', {
    authentication: true,
    rateLimit: { enabled: true, maxRequests: 100 }
  })
  .build();
 
// Tests
export { ConfigBuilder };

Test File

// src/exercise1.test.ts
import { ConfigBuilder } from './exercise1';
 
interface TestConfig {
  name: string;
  value: number;
  nested: {
    enabled: boolean;
  };
}
 
describe('ConfigBuilder', () => {
  it('should set and get values', () => {
    const builder = new ConfigBuilder<TestConfig>();
    
    builder.set('name', 'test');
    expect(builder.get('name')).toBe('test');
  });
  
  it('should handle nested objects', () => {
    const builder = new ConfigBuilder<TestConfig>();
    
    builder.set('nested', { enabled: true });
    expect(builder.get('nested')).toEqual({ enabled: true });
  });
  
  it('should return readonly config', () => {
    const builder = new ConfigBuilder<TestConfig>();
    builder
      .set('name', 'test')
      .set('value', 42)
      .set('nested', { enabled: false });
      
    const config = builder.build();
    
    // @ts-expect-error - should be readonly
    config.name = 'changed';
  });
  
  it('should validate required fields', () => {
    const builder = new ConfigBuilder<TestConfig>();
    builder.set('name', 'test');
    
    expect(builder.validate()).toBe(false); // Missing required fields
  });
});

Hints

Click for hints
  1. Use Partial<T> to store incomplete configuration
  2. Use keyof T and T[K] for type-safe property access
  3. Consider using Required<T> for validation
  4. Use type guards to check if all required fields are set

Solution

Click for solution
type ConfigSchema = Record<string, any>;
 
class ConfigBuilder<T extends ConfigSchema> {
  private config: Partial<T> = {};
  private requiredKeys: Set<keyof T> = new Set();
  
  constructor(required?: (keyof T)[]) {
    if (required) {
      required.forEach(key => this.requiredKeys.add(key));
    }
  }
  
  set<K extends keyof T>(key: K, value: T[K]): this {
    this.config[key] = value;
    return this;
  }
  
  get<K extends keyof T>(key: K): T[K] | undefined {
    return this.config[key];
  }
  
  validate(): boolean {
    // Check if all required keys are present
    for (const key of this.requiredKeys) {
      if (!(key in this.config)) {
        return false;
      }
    }
    
    // Check if all keys in config are valid
    const allKeys = Object.keys(this.config);
    const validKeys = this.getAllKeys();
    
    return allKeys.every(key => validKeys.includes(key));
  }
  
  private getAllKeys(): string[] {
    // In a real implementation, this would use the schema
    return Object.keys(this.config);
  }
  
  build(): Readonly<T> {
    if (!this.validate()) {
      throw new Error('Configuration is invalid or incomplete');
    }
    
    return Object.freeze({ ...this.config }) as Readonly<T>;
  }
}

Exercise 2: Advanced Type Inference

Problem

Create a fluent API query builder that:

  • Infers types from method chains
  • Prevents invalid query combinations
  • Provides compile-time safety
  • Supports multiple database operations

Starter Code

// src/exercise2.ts
type QueryState = {
  select?: string[];
  from?: string;
  where?: Array<{ field: string; op: string; value: any }>;
  orderBy?: Array<{ field: string; direction: 'ASC' | 'DESC' }>;
  limit?: number;
};
 
class QueryBuilder<T = unknown> {
  // TODO: Implement the query builder with proper type inference
  
  select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  from(table: string): this {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  where<K extends keyof T>(
    field: K,
    op: '=' | '!=' | '>' | '<' | '>=' | '<=',
    value: T[K]
  ): this {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  orderBy<K extends keyof T>(
    field: K,
    direction: 'ASC' | 'DESC' = 'ASC'
  ): this {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  limit(count: number): this {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  build(): string {
    // TODO: Build SQL query string
    throw new Error('Not implemented');
  }
}
 
// Usage example:
interface User {
  id: number;
  name: string;
  email: string;
  age: number;
  createdAt: Date;
}
 
const query = new QueryBuilder<User>()
  .select('id', 'name', 'email')
  .from('users')
  .where('age', '>=', 18)
  .where('email', '!=', '')
  .orderBy('createdAt', 'DESC')
  .limit(10)
  .build();
 
console.log(query);
// Should output: SELECT id, name, email FROM users WHERE age >= 18 AND email != '' ORDER BY createdAt DESC LIMIT 10
 
export { QueryBuilder };

Test File

// src/exercise2.test.ts
import { QueryBuilder } from './exercise2';
 
interface TestUser {
  id: number;
  name: string;
  email: string;
  active: boolean;
}
 
describe('QueryBuilder', () => {
  it('should build basic select query', () => {
    const query = new QueryBuilder<TestUser>()
      .select('id', 'name')
      .from('users')
      .build();
      
    expect(query).toBe('SELECT id, name FROM users');
  });
  
  it('should handle where clauses', () => {
    const query = new QueryBuilder<TestUser>()
      .select('id', 'name')
      .from('users')
      .where('active', '=', true)
      .build();
      
    expect(query).toContain('WHERE active = true');
  });
  
  it('should chain multiple where clauses', () => {
    const query = new QueryBuilder<TestUser>()
      .select('id')
      .from('users')
      .where('active', '=', true)
      .where('name', '!=', '')
      .build();
      
    expect(query).toContain('WHERE active = true AND name != \'\'');
  });
  
  it('should type-check field names', () => {
    const builder = new QueryBuilder<TestUser>();
    
    // This should compile
    builder.where('email', '=', 'test@example.com');
    
    // @ts-expect-error - invalid field
    builder.where('invalid', '=', 'value');
  });
  
  it('should type-check values', () => {
    const builder = new QueryBuilder<TestUser>();
    
    // @ts-expect-error - wrong type for boolean field
    builder.where('active', '=', 'not a boolean');
  });
});

Hints

Click for hints
  1. Store query state internally
  2. Use method chaining by returning this
  3. For select, return a new QueryBuilder with transformed type
  4. Use template literal types for SQL building
  5. Escape values properly in the build method

Solution

Click for solution
type QueryState = {
  select?: string[];
  from?: string;
  where?: Array<{ field: string; op: string; value: any }>;
  orderBy?: Array<{ field: string; direction: 'ASC' | 'DESC' }>;
  limit?: number;
};
 
class QueryBuilder<T = unknown> {
  private state: QueryState = {};
  
  select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
    this.state.select = fields as string[];
    return this as any;
  }
  
  from(table: string): this {
    this.state.from = table;
    return this;
  }
  
  where<K extends keyof T>(
    field: K,
    op: '=' | '!=' | '>' | '<' | '>=' | '<=',
    value: T[K]
  ): this {
    if (!this.state.where) {
      this.state.where = [];
    }
    
    this.state.where.push({
      field: field as string,
      op,
      value,
    });
    
    return this;
  }
  
  orderBy<K extends keyof T>(
    field: K,
    direction: 'ASC' | 'DESC' = 'ASC'
  ): this {
    if (!this.state.orderBy) {
      this.state.orderBy = [];
    }
    
    this.state.orderBy.push({
      field: field as string,
      direction,
    });
    
    return this;
  }
  
  limit(count: number): this {
    this.state.limit = count;
    return this;
  }
  
  build(): string {
    const parts: string[] = [];
    
    // SELECT clause
    if (this.state.select && this.state.select.length > 0) {
      parts.push(`SELECT ${this.state.select.join(', ')}`);
    } else {
      parts.push('SELECT *');
    }
    
    // FROM clause
    if (this.state.from) {
      parts.push(`FROM ${this.state.from}`);
    }
    
    // WHERE clause
    if (this.state.where && this.state.where.length > 0) {
      const conditions = this.state.where.map(({ field, op, value }) => {
        const escapedValue = typeof value === 'string' 
          ? `'${value}'` 
          : value;
        return `${field} ${op} ${escapedValue}`;
      });
      parts.push(`WHERE ${conditions.join(' AND ')}`);
    }
    
    // ORDER BY clause
    if (this.state.orderBy && this.state.orderBy.length > 0) {
      const ordering = this.state.orderBy
        .map(({ field, direction }) => `${field} ${direction}`)
        .join(', ');
      parts.push(`ORDER BY ${ordering}`);
    }
    
    // LIMIT clause
    if (this.state.limit !== undefined) {
      parts.push(`LIMIT ${this.state.limit}`);
    }
    
    return parts.join(' ');
  }
}

Exercise 3: Async Error Handling Pattern

Problem

Implement a resilient async operation handler that:

  • Retries failed operations with exponential backoff
  • Supports circuit breaker pattern
  • Provides detailed error information
  • Allows custom retry strategies

Starter Code

// src/exercise3.ts
interface RetryOptions {
  maxAttempts?: number;
  initialDelay?: number;
  maxDelay?: number;
  backoffMultiplier?: number;
  shouldRetry?: (error: unknown, attempt: number) => boolean;
}
 
interface CircuitBreakerOptions {
  failureThreshold?: number;
  resetTimeout?: number;
  halfOpenRequests?: number;
}
 
type AsyncOperation<T> = () => Promise<T>;
 
class ResilientAsync<T> {
  // TODO: Implement resilient async operations
  
  static async retry<T>(
    operation: AsyncOperation<T>,
    options?: RetryOptions
  ): Promise<T> {
    // TODO: Implement retry logic
    throw new Error('Not implemented');
  }
  
  static circuitBreaker<T>(
    operation: AsyncOperation<T>,
    options?: CircuitBreakerOptions
  ): AsyncOperation<T> {
    // TODO: Implement circuit breaker
    throw new Error('Not implemented');
  }
  
  static async withTimeout<T>(
    operation: AsyncOperation<T>,
    timeoutMs: number
  ): Promise<T> {
    // TODO: Implement timeout
    throw new Error('Not implemented');
  }
  
  static async resilient<T>(
    operation: AsyncOperation<T>,
    options?: {
      retry?: RetryOptions;
      circuitBreaker?: CircuitBreakerOptions;
      timeout?: number;
    }
  ): Promise<T> {
    // TODO: Combine all patterns
    throw new Error('Not implemented');
  }
}
 
// Usage example:
async function unreliableApiCall(): Promise<string> {
  if (Math.random() > 0.7) {
    return 'Success!';
  }
  throw new Error('API call failed');
}
 
// Should retry up to 3 times with exponential backoff
const result = await ResilientAsync.retry(unreliableApiCall, {
  maxAttempts: 3,
  initialDelay: 100,
  backoffMultiplier: 2,
});
 
export { ResilientAsync, RetryOptions, CircuitBreakerOptions };

Test File

// src/exercise3.test.ts
import { ResilientAsync } from './exercise3';
 
describe('ResilientAsync', () => {
  describe('retry', () => {
    it('should retry failed operations', async () => {
      let attempts = 0;
      const operation = async () => {
        attempts++;
        if (attempts < 3) {
          throw new Error('Failed');
        }
        return 'Success';
      };
      
      const result = await ResilientAsync.retry(operation, {
        maxAttempts: 3,
      });
      
      expect(result).toBe('Success');
      expect(attempts).toBe(3);
    });
    
    it('should respect max attempts', async () => {
      const operation = async () => {
        throw new Error('Always fails');
      };
      
      await expect(
        ResilientAsync.retry(operation, { maxAttempts: 2 })
      ).rejects.toThrow('Always fails');
    });
    
    it('should use exponential backoff', async () => {
      const delays: number[] = [];
      let lastCall = Date.now();
      
      const operation = async () => {
        const now = Date.now();
        delays.push(now - lastCall);
        lastCall = now;
        throw new Error('Failed');
      };
      
      try {
        await ResilientAsync.retry(operation, {
          maxAttempts: 3,
          initialDelay: 100,
          backoffMultiplier: 2,
        });
      } catch {}
      
      // First delay should be ~100ms, second ~200ms
      expect(delays[1]).toBeGreaterThan(90);
      expect(delays[2]).toBeGreaterThan(180);
    });
  });
  
  describe('circuitBreaker', () => {
    it('should open circuit after failures', async () => {
      let calls = 0;
      const operation = async () => {
        calls++;
        throw new Error('Failed');
      };
      
      const protected = ResilientAsync.circuitBreaker(operation, {
        failureThreshold: 2,
      });
      
      // First two calls should execute
      await expect(protected()).rejects.toThrow();
      await expect(protected()).rejects.toThrow();
      
      // Circuit should be open, no more calls
      await expect(protected()).rejects.toThrow('Circuit breaker is open');
      
      expect(calls).toBe(2);
    });
  });
  
  describe('withTimeout', () => {
    it('should timeout long operations', async () => {
      const operation = async () => {
        await new Promise(resolve => setTimeout(resolve, 1000));
        return 'Done';
      };
      
      await expect(
        ResilientAsync.withTimeout(operation, 100)
      ).rejects.toThrow('Operation timed out');
    });
  });
});

Hints

Click for hints
  1. For retry: Use a loop with try-catch
  2. For backoff: Calculate delay as initialDelay * (backoffMultiplier ** attempt)
  3. For circuit breaker: Track state (closed, open, half-open)
  4. For timeout: Use Promise.race with a timeout promise
  5. Consider using AbortController for cancellation

Solution

Click for solution
interface RetryOptions {
  maxAttempts?: number;
  initialDelay?: number;
  maxDelay?: number;
  backoffMultiplier?: number;
  shouldRetry?: (error: unknown, attempt: number) => boolean;
}
 
interface CircuitBreakerOptions {
  failureThreshold?: number;
  resetTimeout?: number;
  halfOpenRequests?: number;
}
 
type AsyncOperation<T> = () => Promise<T>;
 
enum CircuitState {
  CLOSED = 'CLOSED',
  OPEN = 'OPEN',
  HALF_OPEN = 'HALF_OPEN',
}
 
class ResilientAsync<T> {
  static async retry<T>(
    operation: AsyncOperation<T>,
    options?: RetryOptions
  ): Promise<T> {
    const {
      maxAttempts = 3,
      initialDelay = 100,
      maxDelay = 10000,
      backoffMultiplier = 2,
      shouldRetry = () => true,
    } = options || {};
    
    let lastError: unknown;
    
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      try {
        return await operation();
      } catch (error) {
        lastError = error;
        
        if (attempt === maxAttempts - 1 || !shouldRetry(error, attempt)) {
          throw error;
        }
        
        const delay = Math.min(
          initialDelay * Math.pow(backoffMultiplier, attempt),
          maxDelay
        );
        
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
    
    throw lastError;
  }
  
  static circuitBreaker<T>(
    operation: AsyncOperation<T>,
    options?: CircuitBreakerOptions
  ): AsyncOperation<T> {
    const {
      failureThreshold = 5,
      resetTimeout = 60000,
      halfOpenRequests = 1,
    } = options || {};
    
    let state = CircuitState.CLOSED;
    let failures = 0;
    let lastFailureTime = 0;
    let halfOpenAttempts = 0;
    
    return async () => {
      // Check if circuit should be reset
      if (
        state === CircuitState.OPEN &&
        Date.now() - lastFailureTime > resetTimeout
      ) {
        state = CircuitState.HALF_OPEN;
        halfOpenAttempts = 0;
      }
      
      if (state === CircuitState.OPEN) {
        throw new Error('Circuit breaker is open');
      }
      
      if (state === CircuitState.HALF_OPEN && halfOpenAttempts >= halfOpenRequests) {
        throw new Error('Circuit breaker is half-open, too many requests');
      }
      
      try {
        if (state === CircuitState.HALF_OPEN) {
          halfOpenAttempts++;
        }
        
        const result = await operation();
        
        // Success - reset circuit
        if (state === CircuitState.HALF_OPEN) {
          state = CircuitState.CLOSED;
          failures = 0;
        }
        
        return result;
      } catch (error) {
        failures++;
        lastFailureTime = Date.now();
        
        if (failures >= failureThreshold || state === CircuitState.HALF_OPEN) {
          state = CircuitState.OPEN;
        }
        
        throw error;
      }
    };
  }
  
  static async withTimeout<T>(
    operation: AsyncOperation<T>,
    timeoutMs: number
  ): Promise<T> {
    const timeoutPromise = new Promise<never>((_, reject) => {
      setTimeout(() => reject(new Error('Operation timed out')), timeoutMs);
    });
    
    return Promise.race([operation(), timeoutPromise]);
  }
  
  static async resilient<T>(
    operation: AsyncOperation<T>,
    options?: {
      retry?: RetryOptions;
      circuitBreaker?: CircuitBreakerOptions;
      timeout?: number;
    }
  ): Promise<T> {
    let finalOperation = operation;
    
    if (options?.timeout) {
      const timeout = options.timeout;
      const originalOp = finalOperation;
      finalOperation = () => this.withTimeout(originalOp, timeout);
    }
    
    if (options?.circuitBreaker) {
      finalOperation = this.circuitBreaker(finalOperation, options.circuitBreaker);
    }
    
    if (options?.retry) {
      return this.retry(finalOperation, options.retry);
    }
    
    return finalOperation();
  }
}

Exercise 4: Type-Safe State Management

Problem

Build a Redux-like state management system that:

  • Provides full type safety
  • Supports middleware
  • Has time-travel debugging
  • Allows modular reducers

Starter Code

// src/exercise4.ts
type Action<T = any> = {
  type: string;
  payload?: T;
};
 
type Reducer<S, A extends Action> = (state: S, action: A) => S;
 
type Middleware<S> = (
  store: Store<S>
) => (next: Dispatch) => (action: Action) => any;
 
type Dispatch = (action: Action) => void;
 
interface Store<S> {
  getState(): S;
  dispatch: Dispatch;
  subscribe(listener: () => void): () => void;
}
 
class TypedStore<S, A extends Action = Action> implements Store<S> {
  // TODO: Implement the store
  
  constructor(
    reducer: Reducer<S, A>,
    initialState: S,
    middleware?: Middleware<S>[]
  ) {
    // TODO: Initialize store
    throw new Error('Not implemented');
  }
  
  getState(): S {
    // TODO: Return current state
    throw new Error('Not implemented');
  }
  
  dispatch(action: A): void {
    // TODO: Dispatch action
    throw new Error('Not implemented');
  }
  
  subscribe(listener: () => void): () => void {
    // TODO: Subscribe to changes
    throw new Error('Not implemented');
  }
  
  // Time travel methods
  getHistory(): Array<{ state: S; action: A }> {
    // TODO: Get state history
    throw new Error('Not implemented');
  }
  
  jumpToState(index: number): void {
    // TODO: Jump to historical state
    throw new Error('Not implemented');
  }
}
 
// Usage example:
interface CounterState {
  count: number;
  lastAction?: string;
}
 
type CounterAction = 
  | { type: 'INCREMENT'; payload?: number }
  | { type: 'DECREMENT'; payload?: number }
  | { type: 'RESET' };
 
const counterReducer: Reducer<CounterState, CounterAction> = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { 
        count: state.count + (action.payload || 1),
        lastAction: 'INCREMENT'
      };
    case 'DECREMENT':
      return { 
        count: state.count - (action.payload || 1),
        lastAction: 'DECREMENT'
      };
    case 'RESET':
      return { count: 0, lastAction: 'RESET' };
    default:
      return state;
  }
};
 
// Logger middleware
const logger: Middleware<CounterState> = store => next => action => {
  console.log('Action:', action);
  const result = next(action);
  console.log('New state:', store.getState());
  return result;
};
 
const store = new TypedStore(
  counterReducer,
  { count: 0 },
  [logger]
);
 
export { TypedStore, Reducer, Middleware, Action };

Test File

// src/exercise4.test.ts
import { TypedStore } from './exercise4';
 
interface TestState {
  value: number;
  history: string[];
}
 
type TestAction = 
  | { type: 'ADD'; payload: number }
  | { type: 'MULTIPLY'; payload: number }
  | { type: 'RESET' };
 
const testReducer = (state: TestState, action: TestAction): TestState => {
  switch (action.type) {
    case 'ADD':
      return {
        value: state.value + action.payload,
        history: [...state.history, `ADD ${action.payload}`],
      };
    case 'MULTIPLY':
      return {
        value: state.value * action.payload,
        history: [...state.history, `MULTIPLY ${action.payload}`],
      };
    case 'RESET':
      return { value: 0, history: [] };
    default:
      return state;
  }
};
 
describe('TypedStore', () => {
  it('should handle basic dispatch', () => {
    const store = new TypedStore(
      testReducer,
      { value: 0, history: [] }
    );
    
    store.dispatch({ type: 'ADD', payload: 5 });
    expect(store.getState().value).toBe(5);
    
    store.dispatch({ type: 'MULTIPLY', payload: 3 });
    expect(store.getState().value).toBe(15);
  });
  
  it('should notify subscribers', () => {
    const store = new TypedStore(
      testReducer,
      { value: 0, history: [] }
    );
    
    const listener = jest.fn();
    const unsubscribe = store.subscribe(listener);
    
    store.dispatch({ type: 'ADD', payload: 1 });
    expect(listener).toHaveBeenCalledTimes(1);
    
    unsubscribe();
    store.dispatch({ type: 'ADD', payload: 1 });
    expect(listener).toHaveBeenCalledTimes(1);
  });
  
  it('should apply middleware', () => {
    const middleware = jest.fn(store => next => action => {
      if (action.type === 'ADD') {
        action.payload = action.payload * 2;
      }
      return next(action);
    });
    
    const store = new TypedStore(
      testReducer,
      { value: 0, history: [] },
      [middleware]
    );
    
    store.dispatch({ type: 'ADD', payload: 5 });
    expect(store.getState().value).toBe(10); // Doubled by middleware
  });
  
  it('should support time travel', () => {
    const store = new TypedStore(
      testReducer,
      { value: 0, history: [] }
    );
    
    store.dispatch({ type: 'ADD', payload: 5 });
    store.dispatch({ type: 'MULTIPLY', payload: 3 });
    store.dispatch({ type: 'ADD', payload: 10 });
    
    const history = store.getHistory();
    expect(history).toHaveLength(4); // Initial + 3 actions
    
    store.jumpToState(1); // After first ADD
    expect(store.getState().value).toBe(5);
    
    store.jumpToState(2); // After MULTIPLY
    expect(store.getState().value).toBe(15);
  });
});

Hints

Click for hints
  1. Store state history as array of snapshots
  2. Apply middleware by composing functions
  3. Use closure for subscribe/unsubscribe pattern
  4. Deep clone state to prevent mutations
  5. For time travel, replay actions up to index

Solution

Click for solution
type Action<T = any> = {
  type: string;
  payload?: T;
};
 
type Reducer<S, A extends Action> = (state: S, action: A) => S;
 
type Middleware<S> = (
  store: Store<S>
) => (next: Dispatch) => (action: Action) => any;
 
type Dispatch = (action: Action) => void;
 
interface Store<S> {
  getState(): S;
  dispatch: Dispatch;
  subscribe(listener: () => void): () => void;
}
 
class TypedStore<S, A extends Action = Action> implements Store<S> {
  private state: S;
  private reducer: Reducer<S, A>;
  private listeners: Set<() => void> = new Set();
  private history: Array<{ state: S; action?: A }> = [];
  private dispatch_: Dispatch;
  
  constructor(
    reducer: Reducer<S, A>,
    initialState: S,
    middleware?: Middleware<S>[]
  ) {
    this.reducer = reducer;
    this.state = initialState;
    
    // Store initial state in history
    this.history.push({ state: this.deepClone(initialState) });
    
    // Set up dispatch with middleware
    let dispatch: Dispatch = this.baseDispatch.bind(this);
    
    if (middleware) {
      // Apply middleware in reverse order
      for (let i = middleware.length - 1; i >= 0; i--) {
        dispatch = middleware[i](this)(dispatch);
      }
    }
    
    this.dispatch_ = dispatch;
  }
  
  getState(): S {
    return this.deepClone(this.state);
  }
  
  dispatch(action: A): void {
    this.dispatch_(action);
  }
  
  subscribe(listener: () => void): () => void {
    this.listeners.add(listener);
    
    return () => {
      this.listeners.delete(listener);
    };
  }
  
  getHistory(): Array<{ state: S; action: A }> {
    // Skip the initial state (no action)
    return this.history.slice(1) as Array<{ state: S; action: A }>;
  }
  
  jumpToState(index: number): void {
    if (index < 0 || index >= this.history.length) {
      throw new Error('Invalid history index');
    }
    
    this.state = this.deepClone(this.history[index].state);
    
    // Truncate history to this point
    this.history = this.history.slice(0, index + 1);
    
    // Notify listeners
    this.notifyListeners();
  }
  
  private baseDispatch(action: A): void {
    // Apply reducer
    const newState = this.reducer(this.state, action);
    
    // Only update if state changed
    if (newState !== this.state) {
      this.state = newState;
      
      // Add to history
      this.history.push({
        state: this.deepClone(newState),
        action,
      });
      
      // Notify listeners
      this.notifyListeners();
    }
  }
  
  private notifyListeners(): void {
    this.listeners.forEach(listener => listener());
  }
  
  private deepClone(obj: S): S {
    return JSON.parse(JSON.stringify(obj));
  }
}

Exercise 5: Advanced Type Utilities

Problem

Create a set of advanced TypeScript utilities that:

  • Deep partial/required types
  • Path-based property access
  • Type-safe object merging
  • Conditional type mapping

Starter Code

// src/exercise5.ts
 
// TODO: Implement DeepPartial - makes all properties optional recursively
type DeepPartial<T> = any; // Replace with implementation
 
// TODO: Implement DeepRequired - makes all properties required recursively
type DeepRequired<T> = any; // Replace with implementation
 
// TODO: Implement Paths - gets all possible paths in an object
type Paths<T> = any; // Replace with implementation
 
// TODO: Implement PathValue - gets the type at a specific path
type PathValue<T, P extends Paths<T>> = any; // Replace with implementation
 
// TODO: Implement DeepMerge - merges two types deeply
type DeepMerge<T, U> = any; // Replace with implementation
 
// TODO: Implement ConditionalKeys - gets keys where value matches condition
type ConditionalKeys<T, Condition> = any; // Replace with implementation
 
// Utility functions using the types
class TypedUtils {
  // TODO: Get nested property by path
  static get<T, P extends Paths<T>>(
    obj: T,
    path: P
  ): PathValue<T, P> {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  // TODO: Set nested property by path
  static set<T, P extends Paths<T>>(
    obj: T,
    path: P,
    value: PathValue<T, P>
  ): T {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  // TODO: Deep merge objects
  static merge<T, U>(obj1: T, obj2: U): DeepMerge<T, U> {
    // TODO: Implement
    throw new Error('Not implemented');
  }
  
  // TODO: Pick properties by value type
  static pickByType<T, V>(
    obj: T,
    type: V
  ): Pick<T, ConditionalKeys<T, V>> {
    // TODO: Implement
    throw new Error('Not implemented');
  }
}
 
// Usage examples:
interface User {
  id: number;
  name: string;
  profile: {
    age: number;
    address: {
      street: string;
      city: string;
      country: string;
    };
    preferences: {
      theme: 'light' | 'dark';
      notifications: boolean;
    };
  };
  tags: string[];
}
 
// DeepPartial example
type PartialUser = DeepPartial<User>;
// All properties should be optional, including nested ones
 
// Paths example
type UserPaths = Paths<User>;
// Should be: "id" | "name" | "profile" | "profile.age" | "profile.address" | ...
 
// PathValue example
type CityType = PathValue<User, 'profile.address.city'>;
// Should be: string
 
// Get nested value
const user: User = {
  id: 1,
  name: 'John',
  profile: {
    age: 30,
    address: { street: '123 Main', city: 'NYC', country: 'USA' },
    preferences: { theme: 'dark', notifications: true }
  },
  tags: ['developer']
};
 
const city = TypedUtils.get(user, 'profile.address.city');
// city should be typed as string
 
export { DeepPartial, DeepRequired, Paths, PathValue, DeepMerge, ConditionalKeys, TypedUtils };

Test File

// src/exercise5.test.ts
import { TypedUtils } from './exercise5';
 
interface TestObject {
  a: number;
  b: {
    c: string;
    d: {
      e: boolean;
      f: number[];
    };
  };
  g: string[];
}
 
describe('TypedUtils', () => {
  const testObj: TestObject = {
    a: 1,
    b: {
      c: 'hello',
      d: {
        e: true,
        f: [1, 2, 3],
      },
    },
    g: ['a', 'b'],
  };
  
  describe('get', () => {
    it('should get nested properties', () => {
      expect(TypedUtils.get(testObj, 'a')).toBe(1);
      expect(TypedUtils.get(testObj, 'b.c')).toBe('hello');
      expect(TypedUtils.get(testObj, 'b.d.e')).toBe(true);
      expect(TypedUtils.get(testObj, 'b.d.f')).toEqual([1, 2, 3]);
    });
  });
  
  describe('set', () => {
    it('should set nested properties', () => {
      const updated = TypedUtils.set(testObj, 'b.c', 'world');
      expect(updated.b.c).toBe('world');
      expect(testObj.b.c).toBe('hello'); // Original unchanged
    });
    
    it('should maintain type safety', () => {
      // @ts-expect-error - wrong type
      TypedUtils.set(testObj, 'b.d.e', 'not a boolean');
    });
  });
  
  describe('merge', () => {
    it('should deep merge objects', () => {
      const obj1 = { a: 1, b: { c: 2, d: 3 } };
      const obj2 = { b: { c: 4, e: 5 }, f: 6 };
      
      const merged = TypedUtils.merge(obj1, obj2);
      
      expect(merged).toEqual({
        a: 1,
        b: { c: 4, d: 3, e: 5 },
        f: 6,
      });
    });
  });
  
  describe('pickByType', () => {
    it('should pick properties by type', () => {
      const obj = {
        a: 1,
        b: 'hello',
        c: 2,
        d: 'world',
        e: true,
      };
      
      const numbers = TypedUtils.pickByType(obj, 0 as number);
      expect(numbers).toEqual({ a: 1, c: 2 });
      
      const strings = TypedUtils.pickByType(obj, '' as string);
      expect(strings).toEqual({ b: 'hello', d: 'world' });
    });
  });
});

Hints

Click for hints
  1. For DeepPartial: Use conditional types with recursion
  2. For Paths: Use template literal types with dot notation
  3. For PathValue: Split path string and traverse type
  4. For DeepMerge: Handle conflicts with second type taking precedence
  5. For ConditionalKeys: Use mapped types with conditional

Example patterns:

type IsObject<T> = T extends object ? (T extends any[] ? false : true) : false;
type Join<K, P> = K extends string | number ? 
  P extends string | number ? `${K}.${P}` : K : never;

Solution

Click for solution
// DeepPartial - makes all properties optional recursively
type DeepPartial<T> = T extends object ? {
  [P in keyof T]?: T[P] extends (infer U)[] ? DeepPartial<U>[] :
    T[P] extends object ? DeepPartial<T[P]> : T[P]
} : T;
 
// DeepRequired - makes all properties required recursively
type DeepRequired<T> = T extends object ? {
  [P in keyof T]-?: T[P] extends (infer U)[] ? DeepRequired<U>[] :
    T[P] extends object | undefined ? DeepRequired<NonNullable<T[P]>> : T[P]
} : T;
 
// Helper to check if type is object (not array)
type IsObject<T> = T extends object ? (T extends any[] ? false : true) : false;
 
// Paths - gets all possible paths in an object
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
  { [K in keyof T]-?: K extends string | number ?
    `${K}` | (IsObject<T[K]> extends true ? `${K}.${Paths<T[K], Prev[D]>}` : `${K}`)
    : never
  }[keyof T] : "";
 
// Helper for recursion depth
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
 
// PathValue - gets the type at a specific path
type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}` ?
  K extends keyof T ? PathValue<T[K], Rest> : never :
  P extends keyof T ? T[P] : never;
 
// DeepMerge - merges two types deeply
type DeepMerge<T, U> = T extends object ?
  U extends object ?
    { [K in keyof T | keyof U]: 
      K extends keyof U ?
        K extends keyof T ?
          DeepMerge<T[K], U[K]> :
          U[K] :
        K extends keyof T ? T[K] : never
    } : U : U;
 
// ConditionalKeys - gets keys where value matches condition
type ConditionalKeys<T, Condition> = {
  [K in keyof T]: T[K] extends Condition ? K : never
}[keyof T];
 
// Utility functions using the types
class TypedUtils {
  static get<T, P extends Paths<T>>(
    obj: T,
    path: P
  ): PathValue<T, P> {
    const keys = path.split('.');
    let result: any = obj;
    
    for (const key of keys) {
      result = result[key];
      if (result === undefined) break;
    }
    
    return result;
  }
  
  static set<T, P extends Paths<T>>(
    obj: T,
    path: P,
    value: PathValue<T, P>
  ): T {
    const keys = path.split('.');
    const result = JSON.parse(JSON.stringify(obj)); // Deep clone
    let current: any = result;
    
    for (let i = 0; i < keys.length - 1; i++) {
      if (!(keys[i] in current)) {
        current[keys[i]] = {};
      }
      current = current[keys[i]];
    }
    
    current[keys[keys.length - 1]] = value;
    return result;
  }
  
  static merge<T, U>(obj1: T, obj2: U): DeepMerge<T, U> {
    const result: any = {};
    
    // Copy obj1
    for (const key in obj1) {
      if (obj1.hasOwnProperty(key)) {
        result[key] = obj1[key];
      }
    }
    
    // Merge obj2
    for (const key in obj2) {
      if (obj2.hasOwnProperty(key)) {
        if (
          typeof obj2[key] === 'object' &&
          obj2[key] !== null &&
          !Array.isArray(obj2[key]) &&
          key in result &&
          typeof result[key] === 'object' &&
          result[key] !== null &&
          !Array.isArray(result[key])
        ) {
          result[key] = this.merge(result[key], obj2[key]);
        } else {
          result[key] = obj2[key];
        }
      }
    }
    
    return result;
  }
  
  static pickByType<T, V>(
    obj: T,
    type: V
  ): Pick<T, ConditionalKeys<T, V>> {
    const result: any = {};
    
    for (const key in obj) {
      if (obj.hasOwnProperty(key) && typeof obj[key] === typeof type) {
        result[key] = obj[key];
      }
    }
    
    return result;
  }
}

Final Challenge: Build Your Own Type-Safe ORM

Problem

Create a mini ORM (Object-Relational Mapping) that:

  • Provides type-safe queries
  • Supports relationships
  • Has migration support
  • Includes query builder

This exercise combines all concepts learned!

Starter Template

// Your challenge: implement a type-safe ORM
// Combine everything you've learned to build this
 
interface Model {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}
 
class TypedORM {
  // Your implementation
}
 
// Goal: Support this usage
const User = TypedORM.define('users', {
  id: { type: 'string', primaryKey: true },
  email: { type: 'string', unique: true },
  name: { type: 'string' },
  age: { type: 'number' },
  posts: { type: 'hasMany', model: 'Post' }
});
 
const Post = TypedORM.define('posts', {
  id: { type: 'string', primaryKey: true },
  title: { type: 'string' },
  content: { type: 'text' },
  authorId: { type: 'string' },
  author: { type: 'belongsTo', model: 'User' }
});
 
// Type-safe queries
const users = await User
  .where('age', '>', 18)
  .include('posts')
  .orderBy('createdAt', 'DESC')
  .limit(10)
  .execute();
 
// users[0].posts should be typed correctly

Workshop Completion Checklist

  • Completed all 5 main exercises
  • Ran all tests successfully
  • Experimented with variations
  • Attempted the final challenge
  • Created your own TypeScript utility

Additional Resources

  1. TypeScript Playground: https://www.typescriptlang.org/play
  2. Type Challenges: https://github.com/type-challenges/type-challenges
  3. TypeScript Deep Dive: https://basarat.gitbook.io/typescript/
  4. Utility Types Reference: https://www.typescriptlang.org/docs/handbook/utility-types.html

Next Steps

  1. Apply these patterns in your projects
  2. Create your own type utilities library
  3. Contribute to TypeScript definitions
  4. Teach others what you’ve learned

Remember: The best way to learn TypeScript is to use it in real projects!