TypeFetched/manual/chapter-7-type-safety.md
Casey Collier b85b9a63e2 Initial commit: TypedFetch - Zero-dependency, type-safe HTTP client
Features:
- Zero configuration, just works out of the box
- Runtime type inference and validation
- Built-in caching with W-TinyLFU algorithm
- Automatic retries with exponential backoff
- Circuit breaker for resilience
- Request deduplication
- Offline support with queue
- OpenAPI schema discovery
- Full TypeScript support with type descriptors
- Modular architecture
- Configurable for advanced use cases

Built with bun, ready for npm publishing
2025-07-20 12:35:43 -04:00

22 KiB

Chapter 7: Type Safety Paradise

"In TypeScript we trust, but in runtime we must verify."


The Type Confusion Crisis

Sarah's Weather Buddy was fast, resilient, and cached perfectly. But during a code review, her new teammate Alex pointed at the screen:

"What's the shape of this weather data?"

Sarah squinted. "Uh... it has temp_C and... weatherDesc... I think?"

"You think?" Alex pulled up the console. "Let me show you something terrifying."

// What Sarah wrote
const weather = await tf.get('/api/weather/london')
console.log(weather.temperature)  // undefined
console.log(weather.temp_C)       // undefined  
console.log(weather.data.current_condition[0].temp_C)  // 15

// 3 attempts to find the right property!

"This," Alex said, "is why we need type safety. TypedFetch can solve this."

TypeScript + TypedFetch = Magic

TypedFetch doesn't just fetch data - it understands it:

// Define your types
interface User {
  id: number
  name: string
  email: string
  avatar?: string
}

// TypedFetch knows the type!
const { data } = await tf.get<User>('/api/users/123')
console.log(data.name)  // ✅ TypeScript knows this exists
console.log(data.age)   // ❌ Error: Property 'age' does not exist

// Even better - runtime validation
const { data, validated } = await tf.get<User>('/api/users/123', {
  validate: true
})

if (!validated) {
  console.error('API returned unexpected shape!')
}

Runtime Type Inference: The Revolutionary Feature

But here's where TypedFetch gets magical - it can learn types from actual API responses:

// First request - TypedFetch learns the shape
const user1 = await tf.get('/api/users/1')

// Second request - TypedFetch provides IntelliSense!
const user2 = await tf.get('/api/users/2')
// TypeScript now knows: user2.name, user2.email, etc.

// Check what TypedFetch learned
const typeInfo = tf.getTypeInfo('/api/users/*')
console.log(typeInfo)
// {
//   confidence: 0.95,
//   samples: 2,
//   schema: {
//     type: 'object',
//     properties: {
//       id: { type: 'number' },
//       name: { type: 'string' },
//       email: { type: 'string', format: 'email' }
//     }
//   }
// }

OpenAPI Auto-Discovery: Types Without Writing Types

TypedFetch can find and use OpenAPI schemas automatically:

// TypedFetch discovers OpenAPI spec
await tf.discover('https://api.example.com')

// Now EVERY endpoint has types!
const users = await tf.get('/users')        // ✅ Typed
const posts = await tf.get('/posts')        // ✅ Typed
const comments = await tf.get('/comments')  // ✅ Typed

// See all discovered types
const types = tf.getAllTypes()
console.log(types)
// {
//   '/users': '{ id: number, name: string, ... }',
//   '/posts': '{ id: number, title: string, ... }',
//   ...
// }

Three Levels of Type Safety

Level 1: Manual Types (Good)

Define types yourself:

interface WeatherData {
  current_condition: [{
    temp_C: string
    temp_F: string
    weatherDesc: [{ value: string }]
    humidity: string
    windspeedKmph: string
  }]
  nearest_area: [{
    areaName: [{ value: string }]
    country: [{ value: string }]
  }]
}

const { data } = await tf.get<WeatherData>(`https://wttr.in/${city}?format=j1`)
// Full IntelliSense!

Level 2: Runtime Learning (Better)

Let TypedFetch learn from responses:

// Enable type learning
tf.configure({
  inference: {
    enabled: true,
    minSamples: 3,      // Need 3 samples before confident
    persistence: true    // Save learned types
  }
})

// First few calls - TypedFetch learns
await tf.get('/api/products/1')
await tf.get('/api/products/2')
await tf.get('/api/products/3')

// Now TypedFetch knows the type!
const product = await tf.get('/api/products/4')
// IntelliSense works without manual types!

Level 3: OpenAPI Integration (Best)

Automatic type discovery:

// Option 1: Explicit discovery
await tf.discover('https://api.example.com/openapi.json')

// Option 2: Auto-discovery
tf.configure({
  autoDiscover: true  // Looks for OpenAPI at common paths
})

// Types everywhere!
const result = await tf.get('/any/endpoint')
// Fully typed based on OpenAPI spec

Type Validation: Trust but Verify

Runtime validation catches API changes:

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

// Strict validation
const { data, valid, errors } = await tf.get<User>('/api/user', {
  validate: {
    strict: true,       // Reject extra properties
    coerce: true,       // Try to convert types
    throwOnError: false // Return errors instead of throwing
  }
})

if (!valid) {
  console.error('Validation errors:', errors)
  // [
  //   { path: 'role', expected: 'admin|user', actual: 'superuser' },
  //   { path: 'age', message: 'Unexpected property' }
  // ]
}

// Custom validators
const { data } = await tf.get<User>('/api/user', {
  validate: {
    custom: (data) => {
      if (!data.email.includes('@')) {
        throw new Error('Invalid email format')
      }
      if (data.age && data.age < 0) {
        throw new Error('Age cannot be negative')
      }
    }
  }
})

Weather Buddy 7.0: Fully Typed

Let's add complete type safety to Weather Buddy:

// types.ts
export interface WeatherResponse {
  current_condition: CurrentCondition[]
  nearest_area: NearestArea[]
  request: Request[]
  weather: Weather[]
}

export interface CurrentCondition {
  FeelsLikeC: string
  FeelsLikeF: string
  cloudcover: string
  humidity: string
  localObsDateTime: string
  observation_time: string
  precipInches: string
  precipMM: string
  pressure: string
  pressureInches: string
  temp_C: string
  temp_F: string
  uvIndex: string
  visibility: string
  visibilityMiles: string
  weatherCode: string
  weatherDesc: WeatherDescription[]
  weatherIconUrl: WeatherIcon[]
  winddir16Point: string
  winddirDegree: string
  windspeedKmph: string
  windspeedMiles: string
}

export interface WeatherDescription {
  value: string
}

export interface WeatherIcon {
  value: string
}

export interface NearestArea {
  areaName: ValueWrapper[]
  country: ValueWrapper[]
  latitude: string
  longitude: string
  population: string
  region: ValueWrapper[]
  weatherUrl: ValueWrapper[]
}

export interface ValueWrapper {
  value: string
}

// weather-buddy-7.ts
import { tf } from 'typedfetch'
import type { WeatherResponse, CurrentCondition } from './types'

// Configure TypedFetch with type inference
tf.configure({
  inference: {
    enabled: true,
    persistence: localStorage,
    minSamples: 2
  },
  validation: {
    enabled: true,
    strict: false
  }
})

// Type-safe weather fetching
async function getWeather(city: string): Promise<{
  data: WeatherResponse
  cached: boolean
  inferred: boolean
}> {
  const { data, cached, metadata } = await tf.get<WeatherResponse>(
    `https://wttr.in/${city}?format=j1`,
    {
      validate: true,
      returnMetadata: true
    }
  )
  
  return {
    data,
    cached,
    inferred: metadata.typeSource === 'inference'
  }
}

// Type-safe weather card component
class WeatherCard {
  constructor(private city: string, private element: HTMLElement) {}
  
  async update(): Promise<void> {
    try {
      const { data, cached, inferred } = await getWeather(this.city)
      
      // TypeScript knows all these properties!
      const current = data.current_condition[0]
      const area = data.nearest_area[0]
      
      this.render({
        city: area.areaName[0].value,
        country: area.country[0].value,
        temperature: {
          celsius: parseInt(current.temp_C),
          fahrenheit: parseInt(current.temp_F)
        },
        condition: current.weatherDesc[0].value,
        humidity: parseInt(current.humidity),
        wind: {
          speed: parseInt(current.windspeedKmph),
          direction: current.winddir16Point
        },
        uv: parseInt(current.uvIndex),
        feelsLike: {
          celsius: parseInt(current.FeelsLikeC),
          fahrenheit: parseInt(current.FeelsLikeF)
        },
        cached,
        inferred
      })
    } catch (error) {
      this.renderError(error)
    }
  }
  
  private render(data: WeatherCardData): void {
    this.element.innerHTML = `
      <div class="weather-card">
        <div class="type-indicators">
          ${data.cached ? '⚡ Cached' : '🌐 Fresh'}
          ${data.inferred ? '🧠 Inferred' : '📋 Typed'}
        </div>
        
        <h3>${data.city}, ${data.country}</h3>
        
        <div class="temperature">
          <span class="main-temp">${data.temperature.celsius}°C</span>
          <span class="alt-temp">${data.temperature.fahrenheit}°F</span>
        </div>
        
        <p class="condition">${data.condition}</p>
        
        <div class="details">
          <div>💧 ${data.humidity}%</div>
          <div>💨 ${data.wind.speed} km/h ${data.wind.direction}</div>
          <div>☀️ UV ${data.uv}</div>
          <div>🤔 Feels like ${data.feelsLike.celsius}°C</div>
        </div>
      </div>
    `
  }
  
  private renderError(error: unknown): void {
    if (error instanceof ValidationError) {
      this.element.innerHTML = `
        <div class="error">
          <h4>Invalid API Response</h4>
          <p>The weather API returned unexpected data:</p>
          <ul>
            ${error.errors.map(e => `<li>${e.path}: ${e.message}</li>`).join('')}
          </ul>
        </div>
      `
    } else {
      this.element.innerHTML = `<div class="error">${error.message}</div>`
    }
  }
}

interface WeatherCardData {
  city: string
  country: string
  temperature: {
    celsius: number
    fahrenheit: number
  }
  condition: string
  humidity: number
  wind: {
    speed: number
    direction: string
  }
  uv: number
  feelsLike: {
    celsius: number
    fahrenheit: number
  }
  cached: boolean
  inferred: boolean
}

// Auto-generate types from API
async function exploreAPI(): Promise<void> {
  console.log('🔍 Exploring API endpoints...')
  
  // Make a few requests to learn types
  const cities = ['London', 'Tokyo', 'New York']
  for (const city of cities) {
    await getWeather(city)
  }
  
  // Check what TypedFetch learned
  const learned = tf.getTypeInfo('https://wttr.in/*')
  console.log('📚 Learned type schema:', learned)
  
  // Export for other developers
  const typescript = tf.exportTypes('https://wttr.in/*')
  console.log('📝 TypeScript definitions:', typescript)
}

// Type-safe configuration
interface AppConfig {
  defaultCity: string
  units: 'metric' | 'imperial'
  refreshInterval: number
  maxCities: number
}

class TypedWeatherApp {
  private config: AppConfig
  private cards: Map<string, WeatherCard> = new Map()
  
  constructor(config: Partial<AppConfig> = {}) {
    this.config = {
      defaultCity: 'London',
      units: 'metric',
      refreshInterval: 300000, // 5 minutes
      maxCities: 10,
      ...config
    }
  }
  
  async addCity(city: string): Promise<void> {
    if (this.cards.size >= this.config.maxCities) {
      throw new Error(`Maximum ${this.config.maxCities} cities allowed`)
    }
    
    const element = document.createElement('div')
    const card = new WeatherCard(city, element)
    
    this.cards.set(city, card)
    document.getElementById('cities')?.appendChild(element)
    
    await card.update()
  }
  
  startAutoRefresh(): void {
    setInterval(() => {
      this.cards.forEach(card => card.update())
    }, this.config.refreshInterval)
  }
}

// Usage with full type safety
const app = new TypedWeatherApp({
  defaultCity: 'San Francisco',
  units: 'metric',
  refreshInterval: 60000
})

// This would error at compile time:
// app.addCity(123)  // ❌ Argument of type 'number' is not assignable
// app.config.units = 'kelvin'  // ❌ Type '"kelvin"' is not assignable

Advanced Type Patterns

1. Discriminated Unions for API Responses

Handle different response shapes safely:

// API can return different shapes based on status
type ApiResponse<T> = 
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; code: number }
  | { status: 'loading' }

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  try {
    const { data } = await tf.get<T>(url)
    return { status: 'success', data }
  } catch (error) {
    return { 
      status: 'error', 
      error: error.message,
      code: error.response?.status || 0
    }
  }
}

// Type-safe handling
const response = await fetchData<User>('/api/user')

switch (response.status) {
  case 'success':
    console.log(response.data.name)  // ✅ TypeScript knows data exists
    break
  case 'error':
    console.log(response.error)       // ✅ TypeScript knows error exists
    break
  case 'loading':
    // Handle loading state
    break
}

2. Type Guards for Runtime Validation

// Type guard functions
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj &&
    typeof (obj as any).id === 'number' &&
    typeof (obj as any).name === 'string' &&
    typeof (obj as any).email === 'string'
  )
}

// Use with TypedFetch
const response = await tf.get('/api/user')
if (isUser(response.data)) {
  // TypeScript knows it's a User
  console.log(response.data.email)
} else {
  console.error('Invalid user data received')
}

// Array type guard
function isUserArray(obj: unknown): obj is User[] {
  return Array.isArray(obj) && obj.every(isUser)
}

3. Generic API Client

Build type-safe API clients:

class TypedAPIClient<TEndpoints extends Record<string, any>> {
  constructor(
    private baseURL: string,
    private endpoints: TEndpoints
  ) {}
  
  async get<K extends keyof TEndpoints>(
    endpoint: K,
    params?: Record<string, any>
  ): Promise<TEndpoints[K]> {
    const { data } = await tf.get<TEndpoints[K]>(
      `${this.baseURL}${String(endpoint)}`,
      { params }
    )
    return data
  }
}

// Define your API
interface MyAPI {
  '/users': User[]
  '/users/:id': User
  '/posts': Post[]
  '/posts/:id': Post
  '/comments': Comment[]
}

// Create typed client
const api = new TypedAPIClient<MyAPI>('https://api.example.com', {
  '/users': [] as User[],
  '/users/:id': {} as User,
  '/posts': [] as Post[],
  '/posts/:id': {} as Post,
  '/comments': [] as Comment[]
})

// Full type safety!
const users = await api.get('/users')        // users: User[]
const user = await api.get('/users/:id')     // user: User
// const invalid = await api.get('/invalid')  // ❌ Error!

4. Type Transformation

Transform API responses to match your app's types:

// API returns snake_case
interface APIUser {
  user_id: number
  first_name: string
  last_name: string
  email_address: string
  created_at: string
}

// Your app uses camelCase
interface User {
  userId: number
  firstName: string
  lastName: string
  email: string
  createdAt: Date
}

// Type-safe transformer
function transformUser(apiUser: APIUser): User {
  return {
    userId: apiUser.user_id,
    firstName: apiUser.first_name,
    lastName: apiUser.last_name,
    email: apiUser.email_address,
    createdAt: new Date(apiUser.created_at)
  }
}

// Use with TypedFetch interceptor
tf.addResponseInterceptor(response => {
  if (response.config.url?.includes('/users')) {
    if (Array.isArray(response.data)) {
      response.data = response.data.map(transformUser)
    } else {
      response.data = transformUser(response.data)
    }
  }
  return response
})

Type Inference Deep Dive

How TypedFetch learns types:

// Enable detailed inference
tf.configure({
  inference: {
    enabled: true,
    strategy: 'progressive',  // Learn incrementally
    confidence: 0.9,         // 90% confidence threshold
    maxSamples: 10,          // Learn from up to 10 responses
    persistence: true,       // Save learned types
    
    // Advanced options
    detectPatterns: true,    // Detect email, URL, date formats
    detectEnums: true,       // Detect enum-like fields
    detectOptional: true,    // Detect optional fields
    mergeStrategy: 'union'   // How to handle conflicts
  }
})

// Watch TypedFetch learn
tf.on('typeInferred', ({ endpoint, schema, confidence }) => {
  console.log(`Learned type for ${endpoint}:`, schema)
  console.log(`Confidence: ${confidence * 100}%`)
})

// Make requests - TypedFetch learns
await tf.get('/api/products/1')  // Learns: { id, name, price }
await tf.get('/api/products/2')  // Confirms pattern
await tf.get('/api/products/3')  // High confidence now!

// Check inference details
const inference = tf.getInferenceDetails('/api/products/:id')
console.log(inference)
// {
//   samples: 3,
//   confidence: 0.95,
//   schema: { ... },
//   patterns: {
//     id: 'number:integer',
//     price: 'number:currency',
//     email: 'string:email',
//     created: 'string:iso8601'
//   },
//   optional: ['description'],
//   enums: {
//     status: ['active', 'inactive', 'pending']
//   }
// }

Generating Types from APIs

TypedFetch can generate TypeScript definitions:

// Method 1: From OpenAPI
const types = await tf.generateTypes({
  source: 'https://api.example.com/openapi.json',
  output: './src/types/api.ts',
  options: {
    useUnknownForAny: true,
    generateEnums: true,
    addJSDoc: true
  }
})

// Method 2: From learned types
await tf.exportInferredTypes({
  output: './src/types/inferred.ts',
  filter: (endpoint) => endpoint.startsWith('/api/v2'),
  options: {
    includeConfidence: true,
    minConfidence: 0.8
  }
})

// Method 3: From live exploration
const explorer = tf.createExplorer()
await explorer.explore('https://api.example.com', {
  depth: 3,  // Follow links 3 levels deep
  samples: 5 // Try 5 examples of each endpoint
})
await explorer.generateTypes('./src/types/explored.ts')

Best Practices for Type Safety 🎯

1. Start with Strict Types

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true
  }
}

2. Validate at Boundaries

// Always validate external data
async function getUser(id: string): Promise<User> {
  const { data } = await tf.get(`/api/users/${id}`)
  
  if (!isUser(data)) {
    throw new Error('Invalid user data from API')
  }
  
  return data
}

3. Use Branded Types

// Prevent mixing up similar types
type UserId = string & { readonly brand: unique symbol }
type PostId = string & { readonly brand: unique symbol }

function getUserById(id: UserId) { /* ... */ }
function getPostById(id: PostId) { /* ... */ }

const userId = '123' as UserId
const postId = '456' as PostId

getUserById(userId)  // ✅ OK
getUserById(postId)  // ❌ Error!

4. Prefer Unknown to Any

// ❌ Bad: any disables all checking
async function processData(data: any) {
  console.log(data.foo.bar.baz)  // No errors, but crashes at runtime
}

// ✅ Good: unknown requires checking
async function processData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'foo' in data) {
    // Safe to use data.foo
  }
}

Practice Time! 🏋️

Exercise 1: Type-Safe API Wrapper

Create a fully typed API wrapper:

class TypedAPI {
  // Your code here:
  // - Generic endpoints
  // - Type validation
  // - Transform responses
  // - Handle errors with types
}

Exercise 2: Runtime Type Validator

Build a runtime type validation system:

class TypeValidator<T> {
  // Your code here:
  // - Define schema
  // - Validate at runtime
  // - Produce typed results
  // - Helpful error messages
}

Exercise 3: Type Learning System

Implement type inference from responses:

class TypeLearner {
  // Your code here:
  // - Analyze responses
  // - Build schemas
  // - Track confidence
  // - Export TypeScript
}

Key Takeaways 🎯

  1. TypeScript prevents errors at compile time - Catch bugs before running
  2. Runtime validation catches API changes - Trust but verify
  3. TypedFetch can infer types automatically - Learn from responses
  4. OpenAPI integration provides instant types - No manual definitions
  5. Type guards ensure runtime safety - Validate at boundaries
  6. Generic patterns enable reusable code - Write once, type everywhere
  7. Transform types at the edge - Keep internals clean

Common Pitfalls 🚨

  1. Trusting API types blindly - Always validate
  2. Using 'any' to silence errors - Use 'unknown' instead
  3. Not handling optional fields - Check for undefined
  4. Ignoring runtime validation - TypeScript can't catch everything
  5. Over-typing internal code - Type at boundaries
  6. Fighting type inference - Let TypeScript help

What's Next?

You've achieved type safety nirvana! But how do you modify requests and responses in flight? In Chapter 8, we'll explore interceptors and middleware:

  • Request/response transformation pipelines
  • Authentication interceptors
  • Logging and analytics
  • Request signing
  • Response normalization
  • Building plugin systems

Ready to intercept and transform? See you in Chapter 8! 🚦


Chapter Summary

  • TypeScript + TypedFetch provides compile-time and runtime type safety
  • Manual types are good, runtime inference is better, OpenAPI is best
  • TypedFetch learns types from actual API responses automatically
  • Validation at runtime catches API changes before they break your app
  • Type guards and discriminated unions handle complex response shapes
  • Generic patterns enable fully typed, reusable API clients
  • Always validate external data at system boundaries
  • Weather Buddy 7.0 shows type sources and validates all responses

Next Chapter Preview: Interceptors & Middleware - Transform requests and responses, add authentication, log everything, and build powerful plugin systems.