+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Part 62 of 354

🎯 Default Generic Types: Fallback Type Parameters

Master default generic types in TypeScript to create flexible APIs with sensible fallbacks 🚀

🚀Intermediate
30 min read

Prerequisites

  • Understanding of TypeScript generics 📝
  • Knowledge of type parameters 🔍
  • Familiarity with generic constraints 💻

What you'll learn

  • Create generics with smart default types 🎯
  • Design user-friendly generic APIs 🏗️
  • Balance flexibility with convenience 🛡️
  • Build professional libraries with defaults ✨

🎯 Introduction

Welcome to the world of default generic types! 🎉 In this guide, we’ll explore how to create generic code that’s both powerful and convenient by providing sensible default type parameters.

You’ll discover how default generics are like preset configurations 🎛️ - they work out of the box while still allowing customization! Whether you’re building utility libraries 🔧, designing frameworks 🏗️, or creating reusable components 📦, mastering default generic types is essential for developer-friendly APIs.

By the end of this tutorial, you’ll be confidently creating generics that are easy to use yet flexible when needed! Let’s explore the power of smart defaults! 🏊‍♂️

📚 Understanding Default Generic Types

🤔 Why Default Generic Types?

Default generic types provide fallback values when type parameters aren’t specified:

// ❌ Without defaults - Always requires type specification
interface Container<T> {
  value: T;
  getValue(): T;
  setValue(value: T): void;
}

// Must always specify type
const stringContainer: Container<string> = {
  value: "hello",
  getValue() { return this.value; },
  setValue(value) { this.value = value; }
};

// ✅ With defaults - Works without type specification!
interface SmartContainer<T = string> {
  value: T;
  getValue(): T;
  setValue(value: T): void;
}

// Can use without specifying type (uses default)
const defaultContainer: SmartContainer = {
  value: "hello", // Defaults to string
  getValue() { return this.value; },
  setValue(value) { this.value = value; }
};

// Can still override when needed
const numberContainer: SmartContainer<number> = {
  value: 42,
  getValue() { return this.value; },
  setValue(value) { this.value = value; }
};

// 🎯 Real-world example: API Response
interface ApiResponse<TData = any, TError = Error> {
  success: boolean;
  data?: TData;
  error?: TError;
  timestamp: Date;
}

// Works with defaults
const response1: ApiResponse = {
  success: true,
  data: { message: "Hello" },
  timestamp: new Date()
};

// Or with specific types
const response2: ApiResponse<User, ValidationError> = {
  success: false,
  error: new ValidationError("Invalid email"),
  timestamp: new Date()
};

💡 Default Type Patterns

Common patterns for default generic types:

// 🎯 Progressive defaults - More specific defaults based on other parameters
interface DataStore<
  TValue = any,
  TKey = string,
  TMetadata = { createdAt: Date; updatedAt: Date }
> {
  get(key: TKey): TValue | undefined;
  set(key: TKey, value: TValue, metadata?: TMetadata): void;
  getMetadata(key: TKey): TMetadata | undefined;
}

// 🏗️ Conditional defaults
type DefaultValue<T> = T extends string
  ? ""
  : T extends number
  ? 0
  : T extends boolean
  ? false
  : T extends any[]
  ? []
  : {};

interface FormField<T = string, TDefault = DefaultValue<T>> {
  value: T;
  defaultValue: TDefault;
  validators: Array<(value: T) => boolean>;
  reset(): void;
}

// 🔧 Complex default structures
interface QueryOptions<
  TResult = any,
  TVariables = Record<string, any>,
  TError = Error
> {
  query: string;
  variables?: TVariables;
  onSuccess?: (data: TResult) => void;
  onError?: (error: TError) => void;
  retry?: number;
  timeout?: number;
}

// 🎨 Defaults with constraints
interface Cache<
  K extends string | number = string,
  V = any,
  TOptions extends CacheOptions = DefaultCacheOptions
> {
  get(key: K): V | undefined;
  set(key: K, value: V, options?: TOptions): void;
  clear(): void;
  size(): number;
}

interface CacheOptions {
  ttl?: number;
  priority?: 'low' | 'normal' | 'high';
}

interface DefaultCacheOptions extends CacheOptions {
  ttl: 3600000; // 1 hour
  priority: 'normal';
}

🚀 Advanced Default Patterns

🎨 Smart Default Selection

Creating intelligent default types based on context:

// 🎯 Context-aware defaults
interface Logger<
  TLevel extends LogLevel = 'info',
  TFormatter = TLevel extends 'error' ? ErrorFormatter : StandardFormatter
> {
  log(message: string, level?: TLevel): void;
  setFormatter(formatter: TFormatter): void;
}

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface StandardFormatter {
  format(message: string, level: LogLevel): string;
}

interface ErrorFormatter extends StandardFormatter {
  formatError(error: Error): string;
  includeStackTrace: boolean;
}

// 🏗️ Type inference with defaults
interface Collection<T = any> {
  items: T[];
  add(item: T): void;
  remove(item: T): boolean;
  find(predicate: (item: T) => boolean): T | undefined;
}

// Helper to create collections with inferred types
function createCollection<T = any>(
  initialItems: T[] = []
): Collection<T> {
  return {
    items: initialItems,
    add(item) { this.items.push(item); },
    remove(item) {
      const index = this.items.indexOf(item);
      if (index > -1) {
        this.items.splice(index, 1);
        return true;
      }
      return false;
    },
    find(predicate) {
      return this.items.find(predicate);
    }
  };
}

// Type is inferred from usage
const numbers = createCollection([1, 2, 3]); // Collection<number>
const strings = createCollection<string>(); // Collection<string>
const mixed = createCollection(); // Collection<any> (uses default)

// 🔧 Cascading defaults
interface HttpClient<
  TDefaultResponse = any,
  TDefaultError = HttpError,
  TDefaultHeaders = Record<string, string>
> {
  get<T = TDefaultResponse>(
    url: string,
    options?: RequestOptions<TDefaultHeaders>
  ): Promise<T>;
  
  post<T = TDefaultResponse, D = any>(
    url: string,
    data?: D,
    options?: RequestOptions<TDefaultHeaders>
  ): Promise<T>;
  
  setDefaultHeaders(headers: TDefaultHeaders): void;
  handleError<E = TDefaultError>(error: E): void;
}

interface HttpError {
  status: number;
  message: string;
  code?: string;
}

interface RequestOptions<H = Record<string, string>> {
  headers?: H;
  timeout?: number;
  retry?: number;
}

🔄 Default Type Composition

Building complex defaults from simpler ones:

// 🎯 Composed default types
type DefaultState<T> = {
  data: T | null;
  loading: boolean;
  error: Error | null;
};

interface StateManager<
  TState = any,
  TAction = { type: string; payload?: any },
  TContext = {},
  TFullState = DefaultState<TState>
> {
  state: TFullState;
  dispatch(action: TAction): void;
  subscribe(listener: (state: TFullState) => void): () => void;
  getContext(): TContext;
}

// 🏗️ Nested defaults
interface DatabaseConfig<
  TConnection = DefaultConnection,
  TPool = DefaultPool<TConnection>,
  TOptions = DefaultOptions
> {
  connection: TConnection;
  pool: TPool;
  options: TOptions;
}

interface DefaultConnection {
  host: 'localhost';
  port: 5432;
  database: 'myapp';
  ssl: false;
}

interface DefaultPool<T = DefaultConnection> {
  min: 2;
  max: 10;
  idleTimeout: 30000;
  connectionFactory: () => T;
}

interface DefaultOptions {
  logging: boolean;
  timezone: 'UTC';
  dateStrings: false;
}

// Usage with all defaults
const config1: DatabaseConfig = {
  connection: { host: 'localhost', port: 5432, database: 'myapp', ssl: false },
  pool: {
    min: 2,
    max: 10,
    idleTimeout: 30000,
    connectionFactory: () => ({ host: 'localhost', port: 5432, database: 'myapp', ssl: false })
  },
  options: { logging: true, timezone: 'UTC', dateStrings: false }
};

// 🔧 Factory functions with defaults
function createStore<
  TState = {},
  TActions extends Record<string, Function> = {},
  TGetters extends Record<string, Function> = {}
>(config: {
  state?: TState;
  actions?: TActions;
  getters?: TGetters;
} = {}): Store<TState, TActions, TGetters> {
  return new StoreImpl(
    config.state || {} as TState,
    config.actions || {} as TActions,
    config.getters || {} as TGetters
  );
}

interface Store<TState, TActions, TGetters> {
  state: TState;
  actions: TActions;
  getters: TGetters;
  commit<K extends keyof TState>(key: K, value: TState[K]): void;
}

class StoreImpl<TState, TActions, TGetters> implements Store<TState, TActions, TGetters> {
  constructor(
    public state: TState,
    public actions: TActions,
    public getters: TGetters
  ) {}
  
  commit<K extends keyof TState>(key: K, value: TState[K]): void {
    this.state[key] = value;
  }
}

🎪 Real-World Applications

📊 Event System with Defaults

Building a flexible event system with sensible defaults:

// 🎯 Event system with default types
interface EventEmitter<
  TEventMap extends Record<string, any> = Record<string, any[]>,
  TContext = void,
  TOptions extends EmitterOptions = DefaultEmitterOptions
> {
  on<K extends keyof TEventMap>(
    event: K,
    handler: (payload: TEventMap[K], context: TContext) => void
  ): void;
  
  emit<K extends keyof TEventMap>(
    event: K,
    payload: TEventMap[K],
    context?: TContext
  ): void;
  
  off<K extends keyof TEventMap>(
    event: K,
    handler?: (payload: TEventMap[K], context: TContext) => void
  ): void;
  
  options: TOptions;
}

interface EmitterOptions {
  maxListeners?: number;
  errorHandler?: (error: Error) => void;
  wildcard?: boolean;
}

interface DefaultEmitterOptions extends EmitterOptions {
  maxListeners: 10;
  errorHandler: (error: Error) => console.error(error);
  wildcard: false;
}

// Implementation
class TypedEventEmitter<
  TEventMap extends Record<string, any> = Record<string, any[]>,
  TContext = void,
  TOptions extends EmitterOptions = DefaultEmitterOptions
> implements EventEmitter<TEventMap, TContext, TOptions> {
  private events = new Map<keyof TEventMap, Set<Function>>();
  
  constructor(public options: TOptions) {}
  
  on<K extends keyof TEventMap>(
    event: K,
    handler: (payload: TEventMap[K], context: TContext) => void
  ): void {
    if (!this.events.has(event)) {
      this.events.set(event, new Set());
    }
    
    const handlers = this.events.get(event)!;
    if (this.options.maxListeners && handlers.size >= this.options.maxListeners) {
      throw new Error(`Max listeners (${this.options.maxListeners}) exceeded for event ${String(event)}`);
    }
    
    handlers.add(handler);
  }
  
  emit<K extends keyof TEventMap>(
    event: K,
    payload: TEventMap[K],
    context?: TContext
  ): void {
    const handlers = this.events.get(event);
    if (!handlers) return;
    
    handlers.forEach(handler => {
      try {
        handler(payload, context);
      } catch (error) {
        this.options.errorHandler?.(error as Error);
      }
    });
    
    // Wildcard support
    if (this.options.wildcard && this.events.has('*' as K)) {
      this.events.get('*' as K)!.forEach(handler => {
        handler({ event, payload }, context);
      });
    }
  }
  
  off<K extends keyof TEventMap>(
    event: K,
    handler?: (payload: TEventMap[K], context: TContext) => void
  ): void {
    if (!handler) {
      this.events.delete(event);
    } else {
      this.events.get(event)?.delete(handler);
    }
  }
}

// Usage examples
// Simple usage with all defaults
const emitter1 = new TypedEventEmitter({
  maxListeners: 10,
  errorHandler: console.error,
  wildcard: false
});

emitter1.on('message', (data: any[]) => console.log(data));
emitter1.emit('message', ['Hello']);

// Custom event map
interface AppEvents {
  login: { userId: string; timestamp: Date };
  logout: { userId: string };
  error: { code: string; message: string };
}

const emitter2 = new TypedEventEmitter<AppEvents>({
  maxListeners: 20,
  errorHandler: (error) => console.error('App error:', error),
  wildcard: true
});

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

// With context
interface RequestContext {
  requestId: string;
  userId?: string;
}

const emitter3 = new TypedEventEmitter<AppEvents, RequestContext>({
  maxListeners: 10,
  errorHandler: console.error,
  wildcard: false
});

emitter3.on('error', ({ code, message }, context) => {
  console.log(`Error ${code} in request ${context.requestId}: ${message}`);
});

🔧 Configuration System

Type-safe configuration with defaults:

// 🎯 Configuration with nested defaults
interface Config<
  TApp extends AppConfig = DefaultAppConfig,
  TDatabase extends DatabaseConfig = DefaultDatabaseConfig,
  TCache extends CacheConfig = DefaultCacheConfig
> {
  app: TApp;
  database: TDatabase;
  cache: TCache;
  
  get<K extends keyof this>(key: K): this[K];
  set<K extends keyof this>(key: K, value: this[K]): void;
  merge(partial: DeepPartial<this>): void;
  validate(): ValidationResult;
}

interface AppConfig {
  name: string;
  version: string;
  port: number;
  env: 'development' | 'staging' | 'production';
}

interface DefaultAppConfig extends AppConfig {
  name: 'MyApp';
  version: '1.0.0';
  port: 3000;
  env: 'development';
}

interface DatabaseConfig {
  type: 'postgres' | 'mysql' | 'mongodb';
  host: string;
  port: number;
  database: string;
  username: string;
  password: string;
}

interface DefaultDatabaseConfig extends DatabaseConfig {
  type: 'postgres';
  host: 'localhost';
  port: 5432;
  database: 'myapp_dev';
  username: 'dev';
  password: 'dev';
}

interface CacheConfig {
  enabled: boolean;
  driver: 'memory' | 'redis' | 'memcached';
  ttl: number;
  maxSize: number;
}

interface DefaultCacheConfig extends CacheConfig {
  enabled: true;
  driver: 'memory';
  ttl: 3600;
  maxSize: 1000;
}

// 🏗️ Configuration builder with defaults
class ConfigBuilder<
  TApp extends AppConfig = DefaultAppConfig,
  TDatabase extends DatabaseConfig = DefaultDatabaseConfig,
  TCache extends CacheConfig = DefaultCacheConfig
> {
  private config: Config<TApp, TDatabase, TCache>;
  
  constructor() {
    this.config = {
      app: { name: 'MyApp', version: '1.0.0', port: 3000, env: 'development' } as TApp,
      database: {
        type: 'postgres',
        host: 'localhost',
        port: 5432,
        database: 'myapp_dev',
        username: 'dev',
        password: 'dev'
      } as TDatabase,
      cache: { enabled: true, driver: 'memory', ttl: 3600, maxSize: 1000 } as TCache,
      get(key) { return this[key]; },
      set(key, value) { this[key] = value; },
      merge(partial) { /* implementation */ },
      validate() { return { valid: true, errors: [] }; }
    };
  }
  
  app(config: Partial<TApp>): ConfigBuilder<TApp, TDatabase, TCache> {
    Object.assign(this.config.app, config);
    return this;
  }
  
  database(config: Partial<TDatabase>): ConfigBuilder<TApp, TDatabase, TCache> {
    Object.assign(this.config.database, config);
    return this;
  }
  
  cache(config: Partial<TCache>): ConfigBuilder<TApp, TDatabase, TCache> {
    Object.assign(this.config.cache, config);
    return this;
  }
  
  build(): Config<TApp, TDatabase, TCache> {
    return this.config;
  }
}

// Usage
const config = new ConfigBuilder()
  .app({ port: 8080, env: 'production' })
  .database({ host: 'db.example.com', password: 'secret' })
  .cache({ driver: 'redis', ttl: 7200 })
  .build();

// 🔧 Environment-based defaults
function createConfig<
  TEnv extends 'development' | 'staging' | 'production' = 'development',
  TConfig = TEnv extends 'production' ? ProductionConfig : DevelopmentConfig
>(env?: TEnv): TConfig {
  const configs = {
    development: {
      debug: true,
      logLevel: 'debug',
      cache: false,
      database: { host: 'localhost', ssl: false }
    },
    staging: {
      debug: false,
      logLevel: 'info',
      cache: true,
      database: { host: 'staging.db', ssl: true }
    },
    production: {
      debug: false,
      logLevel: 'error',
      cache: true,
      database: { host: 'prod.db', ssl: true }
    }
  };
  
  return configs[env || 'development'] as TConfig;
}

interface DevelopmentConfig {
  debug: true;
  logLevel: 'debug' | 'info';
  cache: false;
  database: { host: string; ssl: false };
}

interface ProductionConfig {
  debug: false;
  logLevel: 'error' | 'warn';
  cache: true;
  database: { host: string; ssl: true };
}

🎮 Request Handler System

Building a flexible request handling system:

// 🎯 Request handler with layered defaults
interface RequestHandler<
  TRequest = any,
  TResponse = any,
  TContext extends BaseContext = DefaultContext,
  TOptions extends HandlerOptions = DefaultHandlerOptions
> {
  handle(
    request: TRequest,
    context?: TContext
  ): Promise<TResponse>;
  
  middleware: Middleware<TRequest, TResponse, TContext>[];
  options: TOptions;
}

interface BaseContext {
  requestId: string;
  timestamp: Date;
}

interface DefaultContext extends BaseContext {
  user?: { id: string; name: string };
  headers: Record<string, string>;
  metadata: Record<string, any>;
}

interface HandlerOptions {
  timeout?: number;
  retry?: RetryOptions;
  validation?: boolean;
  transform?: boolean;
}

interface DefaultHandlerOptions extends HandlerOptions {
  timeout: 30000;
  retry: { attempts: 3; delay: 1000; backoff: 2 };
  validation: true;
  transform: true;
}

interface RetryOptions {
  attempts: number;
  delay: number;
  backoff: number;
}

interface Middleware<TReq, TRes, TCtx> {
  name: string;
  execute(
    request: TReq,
    context: TCtx,
    next: () => Promise<TRes>
  ): Promise<TRes>;
}

// 🏗️ Handler factory with smart defaults
function createHandler<
  TRequest = any,
  TResponse = any,
  TContext extends BaseContext = DefaultContext
>(
  handler: (request: TRequest, context: TContext) => Promise<TResponse>,
  options?: Partial<HandlerOptions>
): RequestHandler<TRequest, TResponse, TContext> {
  const defaultOptions: DefaultHandlerOptions = {
    timeout: 30000,
    retry: { attempts: 3, delay: 1000, backoff: 2 },
    validation: true,
    transform: true
  };
  
  return {
    handle: handler,
    middleware: [],
    options: { ...defaultOptions, ...options }
  };
}

// 🔧 Type-specific handlers
interface JsonRequestHandler<
  TBody = any,
  TParams = Record<string, string>,
  TQuery = Record<string, string>
> extends RequestHandler<
  JsonRequest<TBody, TParams, TQuery>,
  JsonResponse,
  HttpContext,
  JsonHandlerOptions
> {}

interface JsonRequest<TBody, TParams, TQuery> {
  body: TBody;
  params: TParams;
  query: TQuery;
  method: string;
  path: string;
}

interface JsonResponse<TData = any> {
  status: number;
  data?: TData;
  error?: { code: string; message: string };
  headers?: Record<string, string>;
}

interface HttpContext extends DefaultContext {
  ip: string;
  userAgent: string;
  cookies: Record<string, string>;
}

interface JsonHandlerOptions extends DefaultHandlerOptions {
  parseBody: true;
  compression: boolean;
  cors: CorsOptions;
}

interface CorsOptions {
  origin: string | string[];
  credentials: boolean;
  maxAge: number;
}

// Create specialized handlers
const userHandler = createHandler<
  JsonRequest<{ name: string; email: string }, { id: string }, {}>,
  JsonResponse<{ id: string; name: string; email: string }>,
  HttpContext
>(async (request, context) => {
  // Handler implementation
  return {
    status: 200,
    data: {
      id: request.params.id,
      name: request.body.name,
      email: request.body.email
    }
  };
});

🎮 Hands-On Exercise

Let’s build a flexible data fetching library with smart defaults!

📝 Challenge: Type-Safe Data Fetcher

Create a data fetching system that:

  1. Provides sensible defaults for common use cases
  2. Allows progressive customization
  3. Maintains type safety throughout
  4. Includes caching and retry logic
// Your challenge: Implement this data fetcher
interface DataFetcher<
  TData = any,
  TError = Error,
  TOptions = FetcherOptions
> {
  fetch(url: string, options?: Partial<TOptions>): Promise<TData>;
  prefetch(url: string): void;
  invalidate(url: string): void;
  setDefaults(options: Partial<TOptions>): void;
}

interface FetcherOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  headers?: Record<string, string>;
  body?: any;
  timeout?: number;
  retry?: number;
  cache?: boolean;
  transform?: (data: any) => any;
}

// Example usage to support:
const fetcher = createFetcher<User>({
  timeout: 5000,
  retry: 3,
  cache: true
});

const user = await fetcher.fetch('/api/users/123');

// With custom options
const posts = await createFetcher<Post[]>()
  .fetch('/api/posts', {
    transform: (data) => data.posts
  });

💡 Solution

Click to see the solution
// 🎯 Complete data fetcher implementation
interface FetcherOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  headers?: Record<string, string>;
  body?: any;
  timeout?: number;
  retry?: number;
  cache?: boolean;
  transform?: (data: any) => any;
  onError?: (error: Error) => void;
  validateStatus?: (status: number) => boolean;
}

interface DefaultFetcherOptions extends FetcherOptions {
  method: 'GET';
  headers: { 'Content-Type': 'application/json' };
  timeout: 10000;
  retry: 1;
  cache: true;
  validateStatus: (status: number) => status >= 200 && status < 300;
}

interface CacheEntry<T> {
  data: T;
  timestamp: number;
  ttl: number;
}

interface DataFetcher<
  TData = any,
  TError = Error,
  TOptions extends FetcherOptions = DefaultFetcherOptions
> {
  fetch(url: string, options?: Partial<TOptions>): Promise<TData>;
  prefetch(url: string): void;
  invalidate(url: string): void;
  invalidateAll(): void;
  setDefaults(options: Partial<TOptions>): void;
  getCache(): Map<string, CacheEntry<TData>>;
}

// 🏗️ Implementation
class DataFetcherImpl<
  TData = any,
  TError extends Error = Error,
  TOptions extends FetcherOptions = DefaultFetcherOptions
> implements DataFetcher<TData, TError, TOptions> {
  private cache = new Map<string, CacheEntry<TData>>();
  private inFlightRequests = new Map<string, Promise<TData>>();
  private defaultOptions: TOptions;
  
  constructor(options?: Partial<TOptions>) {
    this.defaultOptions = {
      method: 'GET',
      headers: { 'Content-Type': 'application/json' },
      timeout: 10000,
      retry: 1,
      cache: true,
      validateStatus: (status: number) => status >= 200 && status < 300,
      ...options
    } as TOptions;
  }
  
  async fetch(url: string, options?: Partial<TOptions>): Promise<TData> {
    const mergedOptions = { ...this.defaultOptions, ...options };
    const cacheKey = this.getCacheKey(url, mergedOptions);
    
    // Check cache
    if (mergedOptions.cache) {
      const cached = this.getFromCache(cacheKey);
      if (cached !== null) {
        return cached;
      }
    }
    
    // Check in-flight requests
    if (this.inFlightRequests.has(cacheKey)) {
      return this.inFlightRequests.get(cacheKey)!;
    }
    
    // Create new request
    const requestPromise = this.executeRequest(url, mergedOptions);
    this.inFlightRequests.set(cacheKey, requestPromise);
    
    try {
      const data = await requestPromise;
      
      // Cache result
      if (mergedOptions.cache) {
        this.setCache(cacheKey, data);
      }
      
      return data;
    } finally {
      this.inFlightRequests.delete(cacheKey);
    }
  }
  
  prefetch(url: string): void {
    this.fetch(url).catch(() => {
      // Silently handle prefetch errors
    });
  }
  
  invalidate(url: string): void {
    // Remove all cache entries that start with the URL
    for (const [key] of this.cache) {
      if (key.startsWith(url)) {
        this.cache.delete(key);
      }
    }
  }
  
  invalidateAll(): void {
    this.cache.clear();
  }
  
  setDefaults(options: Partial<TOptions>): void {
    this.defaultOptions = { ...this.defaultOptions, ...options };
  }
  
  getCache(): Map<string, CacheEntry<TData>> {
    return new Map(this.cache);
  }
  
  private async executeRequest(
    url: string,
    options: TOptions
  ): Promise<TData> {
    let lastError: Error | null = null;
    const attempts = options.retry || 1;
    
    for (let i = 0; i < attempts; i++) {
      try {
        const controller = new AbortController();
        const timeoutId = setTimeout(
          () => controller.abort(),
          options.timeout || 10000
        );
        
        const response = await fetch(url, {
          method: options.method,
          headers: options.headers,
          body: options.body ? JSON.stringify(options.body) : undefined,
          signal: controller.signal
        });
        
        clearTimeout(timeoutId);
        
        if (!options.validateStatus?.(response.status)) {
          throw new Error(`Request failed with status ${response.status}`);
        }
        
        let data = await response.json();
        
        if (options.transform) {
          data = options.transform(data);
        }
        
        return data;
      } catch (error) {
        lastError = error as Error;
        
        if (options.onError) {
          options.onError(lastError);
        }
        
        // Don't retry on abort
        if (lastError.name === 'AbortError') {
          break;
        }
        
        // Wait before retry
        if (i < attempts - 1) {
          await this.delay(Math.pow(2, i) * 1000);
        }
      }
    }
    
    throw lastError || new Error('Request failed');
  }
  
  private getCacheKey(url: string, options: TOptions): string {
    const method = options.method || 'GET';
    const body = options.body ? JSON.stringify(options.body) : '';
    return `${method}:${url}:${body}`;
  }
  
  private getFromCache(key: string): TData | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    
    const now = Date.now();
    if (now - entry.timestamp > entry.ttl) {
      this.cache.delete(key);
      return null;
    }
    
    return entry.data;
  }
  
  private setCache(key: string, data: TData, ttl: number = 300000): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl
    });
  }
  
  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// 🔧 Factory with type inference
function createFetcher<TData = any>(
  options?: Partial<FetcherOptions>
): DataFetcher<TData> {
  return new DataFetcherImpl<TData>(options);
}

// 🎨 Specialized fetchers
interface GraphQLFetcher<
  TData = any,
  TVariables = Record<string, any>
> extends DataFetcher<TData, Error, GraphQLOptions<TVariables>> {}

interface GraphQLOptions<TVariables> extends FetcherOptions {
  query: string;
  variables?: TVariables;
  operationName?: string;
}

function createGraphQLFetcher<
  TData = any,
  TVariables = Record<string, any>
>(
  endpoint: string,
  defaultOptions?: Partial<GraphQLOptions<TVariables>>
): GraphQLFetcher<TData, TVariables> {
  const fetcher = createFetcher<TData>({
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...defaultOptions?.headers
    },
    ...defaultOptions
  });
  
  return {
    ...fetcher,
    fetch: async (query: string, options?: Partial<GraphQLOptions<TVariables>>) => {
      const body = {
        query: options?.query || query,
        variables: options?.variables,
        operationName: options?.operationName
      };
      
      return fetcher.fetch(endpoint, {
        ...options,
        body
      });
    }
  };
}

// 💫 Test the implementation
interface User {
  id: string;
  name: string;
  email: string;
}

interface Post {
  id: string;
  title: string;
  content: string;
  authorId: string;
}

async function testDataFetcher() {
  console.log('=== Data Fetcher Test ===\n');
  
  // Basic fetcher with defaults
  const userFetcher = createFetcher<User>({
    cache: true,
    retry: 2,
    timeout: 5000
  });
  
  // Mock fetch for testing
  global.fetch = async (url: any) => ({
    status: 200,
    json: async () => {
      if (url.includes('users')) {
        return { id: '123', name: 'John Doe', email: '[email protected]' };
      }
      if (url.includes('posts')) {
        return {
          posts: [
            { id: '1', title: 'First Post', content: 'Content', authorId: '123' }
          ]
        };
      }
      return null;
    }
  } as any);
  
  try {
    // Fetch user
    console.log('Fetching user...');
    const user = await userFetcher.fetch('/api/users/123');
    console.log('User:', user);
    
    // Fetch from cache
    console.log('\nFetching user again (from cache)...');
    const cachedUser = await userFetcher.fetch('/api/users/123');
    console.log('Cached user:', cachedUser);
    
    // Post fetcher with transform
    const postFetcher = createFetcher<Post[]>({
      transform: (data) => data.posts
    });
    
    console.log('\nFetching posts...');
    const posts = await postFetcher.fetch('/api/posts');
    console.log('Posts:', posts);
    
    // GraphQL fetcher
    const graphql = createGraphQLFetcher<{ user: User }>('/graphql', {
      headers: { 'Authorization': 'Bearer token' }
    });
    
    // Check cache
    console.log('\nCache contents:');
    console.log('User cache:', userFetcher.getCache().size, 'entries');
    console.log('Post cache:', postFetcher.getCache().size, 'entries');
    
    // Invalidate cache
    userFetcher.invalidate('/api/users');
    console.log('\nAfter invalidation:');
    console.log('User cache:', userFetcher.getCache().size, 'entries');
    
  } catch (error) {
    console.error('Error:', error);
  }
  
  console.log('\n✅ Test complete!');
}

// Run test
testDataFetcher();

🎯 Summary

You’ve mastered default generic types in TypeScript! 🎉 You learned how to:

  • 🎯 Create generics with smart default type parameters
  • 🎛️ Design user-friendly APIs with sensible fallbacks
  • 🔄 Build progressive enhancement with optional customization
  • 🏗️ Implement complex default patterns and compositions
  • 📊 Create real-world systems with intelligent defaults
  • ✨ Balance flexibility with convenience in generic design

Default generic types are crucial for creating developer-friendly APIs that are easy to use out of the box while remaining flexible for advanced use cases. You’re now equipped to build professional libraries that delight developers!

Keep building with smart defaults! 🚀