TypeScript-First Mock APIs: Complete Type Safety from Dev to Production
One of the biggest frustrations with traditional mock API tools? You lose type safety. You're writing TypeScript in your frontend, but your mocks return any and you only discover problems at runtime.
What if your mock APIs were as type-safe as the rest of your codebase?
The Type Safety Problem
Traditional Approach: Lost Types
JSON Server:
// ❌ No types - everything is 'any'
const response = await fetch('/api/users/123')
const user = await response.json() // user: any 😱
// Have to manually type everything
const user: User = await response.json() // Hope it matches!
MSW:
// ❌ Manual typing, easy to drift
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({
id: req.params.id,
name: 'John' // Typo 'name' instead of 'fullName'? No error!
}))
})
The Problem:
- No IntelliSense
- No compile-time errors
- Runtime surprises
- Manual type annotations everywhere
- Easy for types to drift from reality
The TypeScript-First Approach
Symulate: Inferred Types Everywhere
import { defineEndpoint, m, type Infer } from '@symulate/sdk'
// 1. Define schema (single source of truth)
const UserSchema = m.object({
id: m.uuid(),
fullName: m.person.fullName(),
email: m.email(),
role: m.helpers.arrayElement(['admin', 'user', 'guest']),
createdAt: m.date(),
settings: m.object({
notifications: m.boolean(),
theme: m.helpers.arrayElement(['light', 'dark', 'auto'])
})
})
// 2. Automatically infer TypeScript type
type User = Infer<typeof UserSchema>
// Result: User is fully typed with all nested properties!
// 3. Define endpoint with full type safety
export const getUser = defineEndpoint<User>({
path: '/api/users/:id',
method: 'GET',
params: [
{
name: 'id',
location: 'path',
required: true,
schema: m.uuid(),
description: 'User ID'
}
],
schema: UserSchema
})
// 4. Usage - full IntelliSense and type checking!
const user = await getUser({ id: 'abc-123' })
console.log(user.fullName) // ✅ IntelliSense works
console.log(user.settings.theme) // ✅ Nested properties typed
console.log(user.name) // ❌ Compile error: 'name' doesn't exist
What You Get:
- ✅ Full IntelliSense in IDE
- ✅ Compile-time type checking
- ✅ Autocomplete for all fields
- ✅ Refactoring support
- ✅ No manual type annotations needed
Real-World Example: E-Commerce API
Let's build a type-safe e-commerce API with complex nested types:
import { defineEndpoint, m, type Infer } from '@symulate/sdk'
// Define reusable schemas
const AddressSchema = m.object({
street: m.location.street(),
city: m.location.city(),
state: m.location.state(),
zipCode: m.location.zipCode(),
country: m.location.country()
})
const ProductSchema = m.object({
id: m.uuid(),
name: m.commerce.productName(),
description: m.commerce.productDescription(),
price: m.commerce.price({ min: 10, max: 1000 }),
currency: m.helpers.arrayElement(['USD', 'EUR', 'GBP']),
inStock: m.boolean(),
category: m.commerce.department(),
images: m.array(m.internet.url(), { min: 1, max: 5 })
})
const OrderItemSchema = m.object({
product: ProductSchema,
quantity: m.number({ min: 1, max: 10 }),
subtotal: m.number()
})
const OrderSchema = m.object({
id: m.uuid(),
orderNumber: m.string(),
customerId: m.uuid(),
items: m.array(OrderItemSchema, { min: 1, max: 5 }),
shippingAddress: AddressSchema,
billingAddress: AddressSchema,
total: m.number(),
status: m.helpers.arrayElement(['pending', 'processing', 'shipped', 'delivered', 'cancelled']),
createdAt: m.date(),
updatedAt: m.date()
})
// Infer types (available throughout your codebase)
export type Address = Infer<typeof AddressSchema>
export type Product = Infer<typeof ProductSchema>
export type OrderItem = Infer<typeof OrderItemSchema>
export type Order = Infer<typeof OrderSchema>
// Define endpoints with full type safety
export const getOrders = defineEndpoint<Order[]>({
path: '/api/orders',
method: 'GET',
params: [
{
name: 'status',
location: 'query',
required: false,
schema: m.string(),
description: 'Filter by order status'
},
{
name: 'page',
location: 'query',
required: false,
schema: m.number(),
example: 1
}
],
schema: OrderSchema,
mock: {
count: 15,
instruction: 'Generate realistic e-commerce orders with varied products and statuses'
}
})
export const getOrder = defineEndpoint<Order>({
path: '/api/orders/:orderId',
method: 'GET',
params: [
{
name: 'orderId',
location: 'path',
required: true,
schema: m.uuid()
}
],
schema: OrderSchema
})
export const createOrder = defineEndpoint<Order>({
path: '/api/orders',
method: 'POST',
params: [
{
name: 'items',
location: 'body',
required: true,
schema: m.array(OrderItemSchema)
},
{
name: 'shippingAddress',
location: 'body',
required: true,
schema: AddressSchema
},
{
name: 'billingAddress',
location: 'body',
required: false,
schema: AddressSchema
}
],
schema: OrderSchema
})
Usage in React Component
import { useEffect, useState } from 'react'
import { getOrders, getOrder, type Order, type OrderItem } from './api/orders'
function OrderList() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
getOrders({ status: 'pending', page: 1 })
.then(data => setOrders(data))
.finally(() => setLoading(false))
}, [])
if (loading) return <div>Loading...</div>
return (
<div>
{orders.map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
)
}
function OrderCard({ order }: { order: Order }) {
// Full type safety on all nested properties!
return (
<div>
<h3>Order #{order.orderNumber}</h3>
<p>Status: {order.status}</p>
<p>Total: {order.total} {order.items[0].product.currency}</p>
{/* IntelliSense works perfectly */}
<address>
{order.shippingAddress.street}<br />
{order.shippingAddress.city}, {order.shippingAddress.state} {order.shippingAddress.zipCode}
</address>
<ul>
{order.items.map((item: OrderItem) => (
<li key={item.product.id}>
{item.quantity}x {item.product.name} - ${item.subtotal}
</li>
))}
</ul>
</div>
)
}
What TypeScript Catches:
const order = await getOrder({ orderId: '123' })
// ✅ All these work with IntelliSense
order.id
order.items[0].product.name
order.shippingAddress.city
order.status
// ❌ Compile errors - caught before runtime!
order.customer // Error: Property 'customer' doesn't exist
order.items[0].price // Error: Use 'product.price' instead
order.shippingAdress // Error: Typo - did you mean 'shippingAddress'?
Advanced Type Safety Patterns
1. Discriminated Unions
Handle different response shapes based on status:
// Different schemas for different statuses
const PendingOrderSchema = m.object({
id: m.uuid(),
status: m.string(), // Will be 'pending'
estimatedShipDate: m.date()
// ... pending-specific fields
})
const ShippedOrderSchema = m.object({
id: m.uuid(),
status: m.string(), // Will be 'shipped'
trackingNumber: m.string(),
carrier: m.string()
// ... shipped-specific fields
})
type PendingOrder = Infer<typeof PendingOrderSchema>
type ShippedOrder = Infer<typeof ShippedOrderSchema>
type Order = PendingOrder | ShippedOrder
// TypeScript narrows the type based on status
function processOrder(order: Order) {
if (order.status === 'pending') {
// TypeScript knows this is PendingOrder
console.log(order.estimatedShipDate) // ✅ Works
} else if (order.status === 'shipped') {
// TypeScript knows this is ShippedOrder
console.log(order.trackingNumber) // ✅ Works
}
}
2. Generic Response Wrappers
Create reusable paginated response types:
// Generic pagination wrapper
function createPaginatedSchema<T>(itemSchema: T) {
return m.object({
data: m.array(itemSchema),
pagination: m.object({
page: m.number(),
limit: m.number(),
total: m.number(),
pages: m.number()
})
})
}
const PaginatedUsersSchema = createPaginatedSchema(UserSchema)
const PaginatedOrdersSchema = createPaginatedSchema(OrderSchema)
// Infer types
type PaginatedUsers = Infer<typeof PaginatedUsersSchema>
type PaginatedOrders = Infer<typeof PaginatedOrdersSchema>
// Use in endpoints
export const getUsers = defineEndpoint<PaginatedUsers>({
path: '/api/users',
method: 'GET',
schema: PaginatedUsersSchema,
mock: { count: 1 }
})
3. Shared Types Across Frontend and Backend
Export types from your SDK for backend use:
// api/types.ts (shared between frontend and backend)
import { type Infer } from '@symulate/sdk'
import { UserSchema, OrderSchema, ProductSchema } from './schemas'
// Export inferred types
export type User = Infer<typeof UserSchema>
export type Order = Infer<typeof OrderSchema>
export type Product = Infer<typeof ProductSchema>
// Backend can import and use these types
// backend/routes/orders.ts
import type { Order, CreateOrderRequest } from '../frontend/api/types'
export async function createOrder(req: Request): Promise<Order> {
const body = req.body as CreateOrderRequest
// Full type safety in backend too!
}
Type Safety in Testing
Unit Tests with Full Type Safety
import { describe, it, expect } from 'vitest'
import { getUser, getOrders, type User, type Order } from './api'
describe('User API', () => {
it('returns properly typed user', async () => {
const user = await getUser({ id: '123' })
// TypeScript ensures we're testing the right shape
expect(user.id).toBeDefined()
expect(user.fullName).toBeDefined()
expect(user.email).toContain('@')
// Compile error if we test wrong properties
// expect(user.name).toBeDefined() // ❌ Error: Property 'name' doesn't exist
})
it('handles user settings correctly', async () => {
const user = await getUser({ id: '123' })
// Nested type safety in tests
expect(user.settings.notifications).toBeTypeOf('boolean')
expect(['light', 'dark', 'auto']).toContain(user.settings.theme)
})
})
describe('Order API', () => {
it('returns array of orders', async () => {
const orders = await getOrders({ page: 1 })
expect(Array.isArray(orders)).toBe(true)
orders.forEach((order: Order) => {
// Type safety ensures we test all required fields
expect(order.id).toBeDefined()
expect(order.items.length).toBeGreaterThan(0)
expect(order.total).toBeTypeOf('number')
})
})
})
Migration to Production: Types Stay!
The best part? When you switch to production, all your types stay intact:
// Development - uses mocks
import { configureSymulate } from '@symulate/sdk'
configureSymulate({
environment: 'development',
symulateApiKey: process.env.SYMULATE_API_KEY,
projectId: process.env.SYMULATE_PROJECT_ID
})
// Production - uses real backend
configureSymulate({
environment: 'production',
backendBaseUrl: 'https://api.yourapp.com'
})
// Your code doesn't change AT ALL
const user = await getUser({ id: '123' })
// Still fully typed! IntelliSense still works!
Zero refactoring needed:
- All types stay the same
- All IntelliSense stays the same
- All tests stay the same
- Only configuration changes
Common Type Safety Pitfalls (And How to Avoid Them)
Pitfall 1: Using any Anywhere
// ❌ Bad - defeats the purpose
const response: any = await getUser({ id: '123' })
// ✅ Good - let type inference work
const response = await getUser({ id: '123' })
// TypeScript automatically knows the type!
Pitfall 2: Manual Type Assertions
// ❌ Bad - runtime can differ
const user = await fetch('/api/users/123')
.then(res => res.json() as User)
// ✅ Good - schema validates at runtime too
const user = await getUser({ id: '123' })
// Type matches reality because it's generated from schema
Pitfall 3: Ignoring Parameter Types
// ❌ Bad - loses type safety on params
export const getUser = defineEndpoint({
path: '/api/users/:id',
// ... no params definition
})
await getUser({ id: 123 }) // Should be string, not number!
// ✅ Good - params are typed and validated
export const getUser = defineEndpoint({
path: '/api/users/:id',
params: [
{
name: 'id',
location: 'path',
required: true,
schema: m.uuid() // Enforces correct type
}
],
// ...
})
await getUser({ id: 123 }) // ❌ Type error caught at compile time!
Performance: Does Type Safety Have a Cost?
Short answer: No.
Type checking happens at compile time, not runtime:
- Zero performance overhead in production
- Bundle size unchanged (types are stripped)
- Same runtime performance as untyped code
You get:
- 100% type safety
- 0% runtime cost
- Faster development (fewer bugs)
- Better IDE experience
Type Safety Checklist
When building type-safe APIs, ensure:
- ✅ Define schemas using
m.object() - ✅ Use
type Infer<typeof Schema>for type extraction - ✅ Define all parameters with proper types
- ✅ Export types for reuse across codebase
- ✅ Use discriminated unions for different response shapes
- ✅ Avoid
anyand manual type assertions - ✅ Test with properly typed data in unit tests
- ✅ Share types between frontend and backend if possible
- ✅ Use strict TypeScript configuration
Real-World Results
Teams using type-safe mock APIs report:
Developer Experience:
- 80% fewer runtime type errors
- 3x faster refactoring (IDE handles it)
- 50% less time debugging API issues
- Instant IntelliSense for all API responses
Code Quality:
- 100% API coverage in TypeScript
- Easier code reviews (types are documented)
- Self-documenting code (types explain structure)
- Safer refactoring (compiler catches breaks)
Example from an Agency:
"We switched to Symulate's type-safe approach and immediately caught 15+ bugs in our existing codebase. Things we assumed worked, but had subtle type mismatches. The frontend now feels as solid as strongly-typed backend code." - Sarah M., Frontend Lead
Conclusion
TypeScript is only as good as your weakest link. If your API calls return any, you've lost type safety where it matters most - at the boundaries of your application.
Type-safe mock APIs give you:
- ✅ Full IntelliSense from dev to production
- ✅ Compile-time error catching
- ✅ Refactoring confidence
- ✅ Self-documenting code
- ✅ Zero runtime overhead
- ✅ Seamless production migration
Stop fighting with any. Start building with confidence.
Ready to try it? Get Symulate with 20K free AI tokens and build your first type-safe API in 10 minutes.
Further Reading:
Ready to try Symulate?
Start building frontends without waiting for backend APIs. Get 100K free AI tokens.
Sign Up Free