š£ļø Road to 10x (Part 2): Building Scalable Clients with the Result Pattern

š Series Navigation
Part 1: API Discovery & Type Safety ā Part 2 (this article): Building Scalable Clients & the Result Pattern Part 3: Next.js Routes, Validation & Deployment
TL;DR
In Part 1, we discovered our API and created type-safe interfaces. Now we're building the client layer that:
- Implements the Result pattern for explicit error handling
- Transforms external API data ā internal TypeScript structures
- Scales effortlessly when adding new API providers
- Handles rate limiting and retries automatically
This is where the architecture becomes truly scalable.
Recap: Where We Left Off
In Part 1, we:
- ā Tested API endpoints in .rest files
- ā Created TypeScript interfaces for all data types
- ā Defined our internal data contracts
Now we're building the client that connects your clean interfaces to messy external APIs.
Step 3: Build the Asynchronous Client Functions
š§āš» "Now comes the fun part - creating the client with all the logic:"
type Result<T> = {
success: true;
data: T;
} | {
success: false;
error: string;
statusCode?: number;
};
const createRealEstateClient = (config: RealEstateConfig): RealEstateClient => {
const baseUrl = config.baseUrl;
const authToken = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
const client: AxiosInstance = axios.create({
baseURL: baseUrl,
headers: {
Authorization: `Basic ${authToken}`,
"X-API-Key": config.apiKey,
Accept: "application/json",
"Content-Type": "application/json",
},
});
// Add response interceptor for error handling
client.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 429) {
// Handle rate limiting for voice agents
await new Promise(resolve => setTimeout(resolve, 1000));
return client.request(error.config);
}
return Promise.reject(error);
}
);
const searchProperties = async (params: PropertySearchParams): Promise<Result<Property[]>> => {
try {
const { data } = await client.post('/api/properties/search', params);
// Transform the data for voice-friendly format
const properties = data.properties.map(transformPropertyForVoice);
return { success: true, data: properties };
} catch (error) {
console.error('Property search failed:', error);
return {
success: false,
error: 'Unable to search properties at this time',
statusCode: error.response?.status
};
}
};
const getPropertyDetails = async (propertyId: string): Promise<Result<Property>> => {
try {
const { data } = await client.get(`/api/properties/${propertyId}`);
const property = transformPropertyForVoice(data);
return { success: true, data: property };
} catch (error) {
console.error('Failed to get property details:', error);
return {
success: false,
error: 'Unable to retrieve property details',
statusCode: error.response?.status
};
}
};
return {
searchProperties,
getPropertyDetails,
// ... other methods
};
};
š "Wait, what's this Result type thing?"
š§āš» "Great question! The Result pattern is a lightweight, type-safe alternative to throwing errors. It's not a standard practice in JavaScript - it's a deliberate design choice that brings huge benefits for voice AI."
Deep Dive: The Result Pattern
Why Result Pattern Over Traditional Try/Catch?
Traditional approach:
// What can go wrong? The type system doesn't tell you!
async function getProperty(id: string): Promise<Property> {
const response = await fetch(`/api/properties/${id}`);
if (!response.ok) throw new Error('Failed'); // Hidden from callers!
return response.json();
}
// Caller has no idea this might throw
const property = await getProperty('123'); // Could crash!
Result pattern approach:
// The return type explicitly shows this can fail
async function getProperty(id: string): Promise<Result<Property>> {
try {
const response = await fetch(`/api/properties/${id}`);
if (!response.ok) {
return { success: false, error: 'Property not found' };
}
return { success: true, data: await response.json() };
} catch (error) {
return { success: false, error: 'Network error' };
}
}
// Caller MUST handle both success and failure
const result = await getProperty('123');
if (result.success) {
console.log(result.data.address); // Type-safe!
} else {
console.log(result.error); // Graceful handling
}
Benefits for Voice AI
1. Explicit error handling
Voice agents can see success: false
and respond appropriately:
- "I'm having trouble finding that property" vs
- "The service is temporarily down"
2. No crashes Unlike thrown exceptions, Result objects are just data. Voice conversations continue smoothly even when things fail.
3. Type-safe branching
TypeScript knows result.data
only exists when success: true
. This prevents entire classes of bugs.
4. Self-correcting agents When validation fails, agents see structured errors and can retry with corrections.
It's a pattern, not a library dependency. You can implement it in 5 lines (like above) or use libraries like neverthrow
or ts-results
. The key: make errors explicit and type-safe.
(Shoutout to Jake Zegil for teaching me this!)
Why This Architecture Scales: The Real Benefits
š "I see you're also handling rate limiting in the interceptor?"
š§āš» "Yes! That's another critical piece for voice agents - let me explain why this architecture actually scales..."
The External Client Pattern: Your Shield Against API Chaos
š "Why create a separate client instead of calling the API directly from my routes?"
š§āš» "Because external APIs are chaos waiting to happen. They change field names, add breaking changes, switch auth methods, or get deprecated entirely. The client is your isolation layer."
Without an external client:
// app/api/voice/properties/route.ts
export async function POST(request: NextRequest) {
const { bedrooms, price } = await request.json();
// Calling external API directly
const response = await fetch('https://api.provider.com/search', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.API_KEY },
body: JSON.stringify({ bedrooms, maxPrice: price })
});
const data = await response.json();
// What if the API changes the response format tomorrow? š„
return NextResponse.json({ properties: data.results });
}
// Problem: You have this same code copy-pasted across 10 different route files!
// When the API changes, you update 10 files and pray you didn't miss one.
With an external client:
// lib/property-api-client.ts - ONE source of truth
const searchProperties = async (params: PropertySearchParams): Promise<Result<Property[]>> => {
try {
// External API changes? Fix it HERE ONCE
const response = await client.post('/search', {
// Map your internal names to whatever the external API wants
bed_count: params.bedrooms, // They changed "bedrooms" ā "bed_count"? No problem!
max_listing_price: params.maxPrice // Changed "maxPrice" ā "max_listing_price"? Fixed!
});
// Transform external format ā your internal Property structure
return {
success: true,
data: response.data.results.map(transformToInternalProperty)
};
} catch (error) {
return { success: false, error: 'Search failed' };
}
};
// All your routes stay clean and untouched!
// app/api/voice/properties/route.ts
export async function POST(request: NextRequest) {
const client = createPropertyClient(config);
const result = await client.searchProperties(params);
// Done! No API details leaked here.
}
Problems This Avoids
1. Cascading changes External API changes don't ripple through your entire codebase. Update one client file, done.
2. Inconsistent error handling Without a client, every route handles errors differently. With a client, error handling is centralized and consistent.
3. Impossible testing How do you mock 47 different fetch calls? With a client, you mock one interface.
4. Rate limit hell The interceptor handles rate limiting ONCE for all requests. Without it, you're adding retry logic to every single route.
Converting External Data to Internal Structs: Your Sanity Saver
š "Why not just use the data structure from the external API?"
š§āš» "Because external APIs don't care about your app. They send garbage field names, inconsistent types, and break things randomly."
Real scenario - external API response:
{
"prop_id": "abc123",
"addr": "123 Main St",
"listing_price": "$500,000", // STRING with dollar sign! š±
"bed_rooms": "3", // Also a STRING! š±
"sqft": 2000,
"img_urls": ["url1", "url2"],
"last_upd": "2024-10-12T10:30:00Z"
}
Your client transforms to clean internal structure:
const transformToInternalProperty = (external: any): Property => ({
id: external.prop_id,
address: external.addr,
price: parseFloat(external.listing_price.replace(/[$,]/g, '')), // Clean it!
bedrooms: parseInt(external.bed_rooms), // Make it a number!
squareFeet: external.sqft,
images: external.img_urls,
lastUpdated: new Date(external.last_upd)
});
// Now your entire app works with a clean Property type:
interface Property {
id: string;
address: string;
price: number; // ALWAYS a number, never a string!
bedrooms: number; // ALWAYS a number
squareFeet: number;
images: string[];
lastUpdated: Date; // ALWAYS a Date object
}
Problems This Avoids Downstream
1. Type chaos
Your voice agent expects price: number
. Without transformation, it gets "$500,000"
and crashes when trying to do math.
2. No cascading changes
External API renames prop_id
to property_id
? You update ONE line in the transform function. Your 47 route files don't change.
3. Voice-friendly data Voice agents need consistent, clean data. "The price is dollar sign five hundred thousand dollars" sounds terrible. "The price is five hundred thousand dollars" is clean.
4. Easy testing
Mock your internal Property
type, not the external API's weird format.
5. Multi-provider support - THE KILLER FEATURE:
// Provider A (our original API)
const providerAClient = {
search: async (params) => {
const data = await fetchFromProviderA(params);
return data.map(transformProviderAToProperty); // ā Property[]
}
};
// Provider B (new API with completely different format)
const providerBClient = {
search: async (params) => {
const data = await fetchFromProviderB(params);
return data.map(transformProviderBToProperty); // ā Property[]
}
};
// Your voice agent doesn't know or care which provider!
// Both return the same Property[] structure!
const client = useProviderB ? providerBClient : providerAClient;
const result = await client.search(params);
// Or combine multiple providers:
const [resultsA, resultsB] = await Promise.all([
providerAClient.search(params),
providerBClient.search(params)
]);
const allProperties = [
...(resultsA.success ? resultsA.data : []),
...(resultsB.success ? resultsB.data : [])
]; // Both use the same Property type! š
The Scalability Moment
š "So when I need to add a second API provider, what changes?"
š§āš» "You create ONE new client file. That's it. Your voice routes don't change at all."
// lib/zillow-client.ts (NEW FILE)
const createZillowClient = (config: ZillowConfig) => {
const client = axios.create({ /* Zillow setup */ });
const searchProperties = async (params: PropertySearchParams): Promise<Result<Property[]>> => {
const response = await client.get('/zillow/search', {
params: translateToZillowFormat(params)
});
// Transform Zillow's format ā your internal Property structure
const properties = response.data.results.map(zillow => ({
id: zillow.zpid,
address: zillow.streetAddress,
price: zillow.price,
// ... other fields
}));
return { success: true, data: properties };
};
return { searchProperties };
};
// In your route:
const client = useZillow
? createZillowClient(zillowConfig)
: createPropertyClient(propertyConfig);
const result = await client.searchProperties(params);
// Same interface, different implementation. Zero changes to voice agent code!
For Users:
- When Provider A goes down, you switch to Provider B without the voice agent crashing
- You can call multiple providers in parallel for faster results
- Properties from any source look/sound identical in conversation
For Developers:
- Add new API providers in hours, not weeks
- Debug one client at a time, not spaghetti code across 20 files
- Sleep well knowing external API changes won't cascade through your app
What We've Built
At this point, you have:
ā Type-safe client functions ā Result pattern for explicit error handling ā Transformation layer (external ā internal) ā Rate limiting and retry logic ā Architecture that scales to multiple providers
What's Next?
In Part 3, we'll wire this client to Next.js API routes with:
- Zod schema validation for runtime type safety
- Voice-friendly error messages
- Self-correcting feedback loops
- Complete end-to-end testing
The architecture is solid. Now let's build the API layer! š
ā Part 1: API Discovery & Type Safety Continue to Part 3: Routes, Validation & Deployment ā
- Seif š§āš»