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
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 🎯
- TypeScript prevents errors at compile time - Catch bugs before running
- Runtime validation catches API changes - Trust but verify
- TypedFetch can infer types automatically - Learn from responses
- OpenAPI integration provides instant types - No manual definitions
- Type guards ensure runtime safety - Validate at boundaries
- Generic patterns enable reusable code - Write once, type everywhere
- Transform types at the edge - Keep internals clean
Common Pitfalls 🚨
- Trusting API types blindly - Always validate
- Using 'any' to silence errors - Use 'unknown' instead
- Not handling optional fields - Check for undefined
- Ignoring runtime validation - TypeScript can't catch everything
- Over-typing internal code - Type at boundaries
- 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.