Back to Blog
10 min read

TypeScript-First Mock APIs: Complete Type Safety from Dev to Production

Learn how to build fully type-safe mock APIs with TypeScript. Get IntelliSense, compile-time errors, and seamless production migration without losing type safety.

TypeScriptType SafetyDeveloper ExperienceBest Practices

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 any and 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