Client
The BaseClient provides a type-safe HTTP client that automatically infers types from your handler configurations. It handles URL building, validation, and response parsing.
Overview
The client uses the same handler configurations as the server, ensuring type safety end-to-end:
typescript
import { BaseClient } from '@adi-family/http'
import { getUserConfig } from './contracts'
const client = new BaseClient({
baseUrl: 'http://localhost:3000'
})
// Fully type-safe!
const user = await client.run(getUserConfig, {
params: { id: '123' }
})
console.log(user.name) // TypeScript knows this existsCreating a Client
Basic Configuration
typescript
import { BaseClient } from '@adi-family/http'
const client = new BaseClient({
baseUrl: 'http://localhost:3000'
})With Default Headers
typescript
const client = new BaseClient({
baseUrl: 'http://localhost:3000',
headers: {
'Authorization': 'Bearer your-token',
'X-API-Version': '1.0'
}
})With Custom Fetch
typescript
const client = new BaseClient({
baseUrl: 'http://localhost:3000',
fetch: customFetch // Use a custom fetch implementation
})Making Requests
The client.run() method is used for all requests:
typescript
const result = await client.run(handlerConfig, options)GET Requests
typescript
// Simple GET
const user = await client.run(getUserConfig, {
params: { id: '123' }
})
// GET with query parameters
const users = await client.run(listUsersConfig, {
query: { page: 1, limit: 20, search: 'john' }
})POST Requests
typescript
const newUser = await client.run(createUserConfig, {
body: {
name: 'John Doe',
email: 'john@example.com'
}
})PUT/PATCH Requests
typescript
const updatedUser = await client.run(updateUserConfig, {
params: { id: '123' },
body: {
name: 'Jane Doe',
email: 'jane@example.com'
}
})DELETE Requests
typescript
await client.run(deleteUserConfig, {
params: { id: '123' }
})Request Options
The second parameter to client.run() accepts:
typescript
interface RunOptions<TParams, TQuery, TBody> {
params?: TParams // URL parameters
query?: TQuery // Query string parameters
body?: TBody // Request body
headers?: Record<string, string> // Additional headers
}Combining Options
typescript
const task = await client.run(getProjectTaskConfig, {
params: { projectId: '123', taskId: '456' },
query: { include: 'comments' },
headers: { 'X-Request-ID': 'abc123' }
})Type Safety
The client provides full type inference:
typescript
// ✅ TypeScript knows the exact shape of the response
const user = await client.run(getUserConfig, {
params: { id: '123' }
})
user.name // ✅ string
user.email // ✅ string
user.invalid // ❌ TypeScript error
// ✅ TypeScript validates request parameters
await client.run(getUserConfig, {
params: { id: 123 } // ❌ TypeScript error - should be string
})
// ✅ TypeScript validates query parameters
await client.run(listUsersConfig, {
query: { limit: 'not a number' } // ❌ TypeScript error
})Error Handling
Basic Error Handling
typescript
try {
const user = await client.run(getUserConfig, {
params: { id: '123' }
})
} catch (error) {
console.error('Request failed:', error)
}HTTP Status Codes
typescript
try {
const user = await client.run(getUserConfig, {
params: { id: '123' }
})
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('404')) {
console.error('User not found')
} else if (error.message.includes('401')) {
console.error('Unauthorized')
} else if (error.message.includes('500')) {
console.error('Server error')
}
}
}Validation Errors
The server returns validation errors with HTTP 400:
typescript
try {
await client.run(createUserConfig, {
body: {
name: '', // Invalid - too short
email: 'not-an-email' // Invalid format
}
})
} catch (error) {
// Server returned:
// Status: 400
// Body: {
// error: 'Validation failed',
// details: [
// { path: ['name'], message: 'String must contain at least 1 character(s)' },
// { path: ['email'], message: 'Invalid email' }
// ]
// }
}Advanced Usage
Custom Fetch with Retry Logic
typescript
const retryClient = new BaseClient({
baseUrl: 'http://localhost:3000',
fetch: async (url, options) => {
const maxRetries = 3
const retryDelay = 1000
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options)
if (response.status >= 500) {
throw new Error(`Server error: ${response.status}`)
}
return response
} catch (error) {
if (attempt === maxRetries - 1) {
throw error
}
// Wait before retrying
await new Promise(resolve =>
setTimeout(resolve, retryDelay * (attempt + 1))
)
}
}
throw new Error('Max retries exceeded')
}
})Request Interceptor
typescript
const interceptClient = new BaseClient({
baseUrl: 'http://localhost:3000',
fetch: async (url, options) => {
// Log request
console.log(`${options?.method || 'GET'} ${url}`)
// Add authentication
const token = await getAuthToken()
const headers = new Headers(options?.headers)
headers.set('Authorization', `Bearer ${token}`)
// Make request
const response = await fetch(url, { ...options, headers })
// Log response
console.log(`Response: ${response.status}`)
return response
}
})Response Caching
typescript
const cache = new Map<string, any>()
const cachedClient = new BaseClient({
baseUrl: 'http://localhost:3000',
fetch: async (url, options) => {
const method = options?.method || 'GET'
// Only cache GET requests
if (method === 'GET') {
const cacheKey = url.toString()
if (cache.has(cacheKey)) {
const cached = cache.get(cacheKey)
return new Response(JSON.stringify(cached), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
const response = await fetch(url, options)
const data = await response.clone().json()
cache.set(cacheKey, data)
return response
}
return fetch(url, options)
}
})Multiple Environments
typescript
const createClient = (env: 'dev' | 'staging' | 'prod') => {
const baseUrls = {
dev: 'http://localhost:3000',
staging: 'https://staging.api.example.com',
prod: 'https://api.example.com'
}
return new BaseClient({
baseUrl: baseUrls[env]
})
}
const client = createClient(process.env.NODE_ENV === 'production' ? 'prod' : 'dev')Complete Example
Shared Contracts
typescript
// contracts/users.ts
import { route } from '@adi-family/http'
import { z } from 'zod'
import type { HandlerConfig } from '@adi-family/http'
export const listUsersConfig = {
method: 'GET',
route: route.static('/api/users'),
query: {
schema: z.object({
page: z.number().min(1).optional().default(1),
limit: z.number().min(1).max(100).optional().default(10),
search: z.string().optional()
})
},
response: {
schema: z.object({
data: z.array(z.object({
id: z.string(),
name: z.string(),
email: z.string()
})),
total: z.number(),
page: z.number(),
limit: z.number()
})
}
} as const satisfies HandlerConfig
export const getUserConfig = {
method: 'GET',
route: route.dynamic('/api/users/:id', z.object({ id: z.string() })),
response: {
schema: z.object({
id: z.string(),
name: z.string(),
email: z.string()
})
}
} as const satisfies HandlerConfig
export const createUserConfig = {
method: 'POST',
route: route.static('/api/users'),
body: {
schema: z.object({
name: z.string().min(1).max(255),
email: z.string().email()
})
},
response: {
schema: z.object({
id: z.string(),
name: z.string(),
email: z.string()
})
}
} as const satisfies HandlerConfigClient Usage
typescript
// client/users.ts
import { BaseClient } from '@adi-family/http'
import { listUsersConfig, getUserConfig, createUserConfig } from '../contracts/users'
const client = new BaseClient({
baseUrl: 'http://localhost:3000',
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
})
// List users with pagination
const users = await client.run(listUsersConfig, {
query: { page: 1, limit: 20, search: 'john' }
})
console.log(`Found ${users.total} users`)
users.data.forEach(user => {
console.log(`${user.name} (${user.email})`)
})
// Get single user
const user = await client.run(getUserConfig, {
params: { id: '123' }
})
console.log(user.name)
// Create user
const newUser = await client.run(createUserConfig, {
body: {
name: 'John Doe',
email: 'john@example.com'
}
})
console.log(`Created user with ID: ${newUser.id}`)Best Practices
1. Share Contracts
Keep contracts in a shared package:
typescript
// packages/contracts/users.ts
export const getUserConfig = { ... }
// packages/server/handlers/users.ts
import { getUserConfig } from '@your-org/contracts'
// packages/client/api/users.ts
import { getUserConfig } from '@your-org/contracts'2. Create Client Wrapper
Wrap the client for easier usage:
typescript
// client/api.ts
import { BaseClient } from '@adi-family/http'
import * as userConfigs from '../contracts/users'
import * as projectConfigs from '../contracts/projects'
class ApiClient {
private client: BaseClient
constructor(baseUrl: string, token?: string) {
this.client = new BaseClient({
baseUrl,
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
}
users = {
list: (query?: any) => this.client.run(userConfigs.listUsersConfig, { query }),
get: (id: string) => this.client.run(userConfigs.getUserConfig, { params: { id } }),
create: (body: any) => this.client.run(userConfigs.createUserConfig, { body })
}
projects = {
list: () => this.client.run(projectConfigs.listProjectsConfig, {}),
get: (id: string) => this.client.run(projectConfigs.getProjectConfig, { params: { id } })
}
}
export const api = new ApiClient(process.env.API_URL!, process.env.API_TOKEN)3. Handle Errors Consistently
typescript
// client/utils.ts
export async function apiRequest<T>(
fn: () => Promise<T>
): Promise<{ data?: T; error?: string }> {
try {
const data = await fn()
return { data }
} catch (error) {
if (error instanceof Error) {
return { error: error.message }
}
return { error: 'Unknown error' }
}
}
// Usage
const { data, error } = await apiRequest(() =>
client.run(getUserConfig, { params: { id: '123' } })
)
if (error) {
console.error(error)
} else {
console.log(data.name)
}Next Steps
- Handlers - Learn how to implement server-side handlers
- Validation - Deep dive into Zod validation
- Examples - See complete working examples