Advanced TopicsTypeScript

TypeScript

MockMaster is built with TypeScript from the ground up and provides comprehensive type definitions for all APIs. This guide covers everything you need to leverage TypeScript’s full power with MockMaster.


Why TypeScript with MockMaster?

TypeScript enhances your MockMaster workflow with:

  • Type safety - Catch errors at compile time
  • IntelliSense - Auto-completion in your IDE
  • Refactoring - Rename and restructure with confidence
  • Documentation - Types serve as inline documentation
  • Better DX - Fewer runtime errors, faster development

Type-Safe Factories

Factories can be strongly typed to ensure your mock data matches your interfaces:

Basic Factory Types

import { defineFactory, build, fake, type Factory } from '@mockmaster/data'
 
// Define your interface
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
  createdAt: Date
}
 
// Type the factory
const userFactory: Factory<User> = defineFactory<User>('user', {
  id: (ctx) => ctx.sequence('user'),
  name: () => fake.person.fullName(),
  email: () => fake.internet.email(),
  role: () => fake.helpers.arrayElement(['admin', 'user', 'guest'] as const),
  createdAt: () => fake.date.past()
})
 
// TypeScript knows the return type
const user: User = build(userFactory)
//    ^? const user: User
 
// IntelliSense shows all properties
console.log(user.id, user.name, user.email, user.role)

Type Inference

Let TypeScript infer types when possible:

// Without explicit type annotation - TypeScript infers the type
const productFactory = defineFactory('product', {
  id: (ctx) => ctx.sequence('product'),
  name: () => fake.commerce.productName(),
  price: () => parseFloat(fake.commerce.price()),
  inStock: () => fake.datatype.boolean()
})
 
// Type is inferred from factory definition
const product = build(productFactory)
//    ^? const product: { id: number; name: string; price: number; inStock: boolean }

Complex Nested Types

interface Address {
  street: string
  city: string
  state: string
  zipCode: string
  country: string
}
 
interface Company {
  id: number
  name: string
  industry: string
  employees: number
  headquarters: Address
}
 
const addressFactory = defineFactory<Address>('address', {
  street: () => fake.location.streetAddress(),
  city: () => fake.location.city(),
  state: () => fake.location.state(),
  zipCode: () => fake.location.zipCode(),
  country: () => fake.location.country()
})
 
const companyFactory = defineFactory<Company>('company', {
  id: (ctx) => ctx.sequence('company'),
  name: () => fake.company.name(),
  industry: () => fake.commerce.department(),
  employees: () => fake.number.int({ min: 10, max: 10000 }),
  headquarters: () => build(addressFactory)  // Nested factory
})
 
// Fully type-safe
const company: Company = build(companyFactory)
console.log(company.headquarters.city)  // TypeScript knows all nested properties

Type-Safe Scenarios

Typed Recordings

Create recordings with explicit response types:

import { createRecording, type Recording } from '@mockmaster/msw-adapter'
 
interface UserResponse {
  id: number
  name: string
  email: string
  verified: boolean
}
 
// Type the response body
const recording = createRecording<UserResponse>(
  {
    method: 'GET',
    url: 'https://api.example.com/users/123',
    path: '/users/:id',
    timestamp: Date.now()
  },
  {
    status: 200,
    body: {
      id: 123,
      name: 'John Doe',
      email: 'john@example.com',
      verified: true
    },
    timestamp: Date.now()
  }
)
 
// TypeScript validates the body matches UserResponse
// This would error: body: { id: '123' } // id should be number, not string

Typed Handlers

Replay handlers can return typed responses:

import { createReplayHandler, type ReplayHandler } from '@mockmaster/msw-adapter'
 
interface ApiResponse<T> {
  status: number
  body: T
  headers?: Record<string, string>
}
 
const handler = createReplayHandler(scenario)
 
// Type the response
const response = handler({ method: 'GET', path: '/users/123' }) as ApiResponse<UserResponse> | undefined
 
if (response) {
  // TypeScript knows the shape of response.body
  const userId: number = response.body.id
  const userName: string = response.body.name
}

Generic Scenarios

Create reusable generic functions for scenarios:

import { Scenario, Recording, createScenario, addRecordingToScenario } from '@mockmaster/msw-adapter'
 
// Generic function to create a scenario with typed recordings
function createTypedScenario<T>(
  name: string,
  description: string,
  recordings: Array<Recording<T>>
): Scenario {
  let scenario = createScenario(name, description)
 
  for (const recording of recordings) {
    scenario = addRecordingToScenario(scenario, recording)
  }
 
  return scenario
}
 
// Use it
interface Product {
  id: number
  name: string
  price: number
}
 
const productRecordings: Array<Recording<Product[]>> = [
  createRecording<Product[]>(
    { method: 'GET', path: '/products', timestamp: Date.now() },
    {
      status: 200,
      body: [
        { id: 1, name: 'Widget', price: 9.99 },
        { id: 2, name: 'Gadget', price: 19.99 }
      ],
      timestamp: Date.now()
    }
  )
]
 
const productScenario = createTypedScenario('products', 'Product API', productRecordings)

Type Guards and Assertions

Use type guards to safely handle responses:

// Type guard for successful responses
function isSuccessResponse<T>(
  response: { status: number; body: T } | undefined
): response is { status: number; body: T } {
  return response !== undefined && response.status >= 200 && response.status < 300
}
 
// Usage
const response = handler({ method: 'GET', path: '/users/123' })
 
if (isSuccessResponse(response)) {
  // TypeScript knows response is defined here
  console.log(response.body)
} else {
  console.error('Request failed or no match')
}

Type Assertions

// When you know the response type
const response = handler({ method: 'GET', path: '/users' })!
const users = response.body as User[]
 
// Safer with type guard
function assertUserArray(body: unknown): asserts body is User[] {
  if (!Array.isArray(body)) {
    throw new Error('Expected array')
  }
}
 
const response2 = handler({ method: 'GET', path: '/users' })
if (response2) {
  assertUserArray(response2.body)
  // TypeScript now knows response2.body is User[]
  response2.body.forEach(user => console.log(user.name))
}

Discriminated Unions

Handle different response types with discriminated unions:

// Define response types
type ApiSuccess<T> = {
  status: 'success'
  data: T
}
 
type ApiError = {
  status: 'error'
  message: string
  code: string
}
 
type ApiResponse<T> = ApiSuccess<T> | ApiError
 
// Type guard
function isSuccessResponse<T>(response: ApiResponse<T>): response is ApiSuccess<T> {
  return response.status === 'success'
}
 
// Factory
interface UserData {
  id: number
  name: string
}
 
const successResponse = createRecording<ApiResponse<UserData>>(
  { method: 'GET', path: '/users/1', timestamp: Date.now() },
  {
    status: 200,
    body: {
      status: 'success',
      data: { id: 1, name: 'Alice' }
    },
    timestamp: Date.now()
  }
)
 
const errorResponse = createRecording<ApiResponse<UserData>>(
  { method: 'GET', path: '/users/999', timestamp: Date.now() },
  {
    status: 404,
    body: {
      status: 'error',
      message: 'User not found',
      code: 'USER_NOT_FOUND'
    },
    timestamp: Date.now()
  }
)
 
// Usage with type narrowing
const handler = createReplayHandler(scenario)
const response = handler({ method: 'GET', path: '/users/1' })
 
if (response && isSuccessResponse(response.body)) {
  console.log(response.body.data.name)  // TypeScript knows this is UserData
} else if (response) {
  console.error(response.body.message)  // TypeScript knows this is ApiError
}

Utility Types

Create utility types for common patterns:

// Extract response type from a recording
type ResponseBody<T> = T extends Recording<infer R> ? R : never
 
// Example
const userRecording = createRecording<User>({...}, {...})
type UserResponseType = ResponseBody<typeof userRecording>  // User
 
// Generic scenario builder
type ScenarioBuilder<T> = {
  name: string
  recordings: Array<Recording<T>>
  build: () => Scenario
}
 
function createScenarioBuilder<T>(name: string, description: string): ScenarioBuilder<T> {
  const recordings: Array<Recording<T>> = []
 
  return {
    name,
    recordings,
    build() {
      let scenario = createScenario(name, description)
      for (const recording of recordings) {
        scenario = addRecordingToScenario(scenario, recording)
      }
      return scenario
    }
  }
}
 
// Use it
const builder = createScenarioBuilder<User>('users', 'User API')
builder.recordings.push(
  createRecording<User>({...}, {...})
)
const scenario = builder.build()

Strict Mode

Enable strict TypeScript checks for maximum safety:

tsconfig.json

{
  "compilerOptions": {
    "strict": true,                  // Enable all strict checks
    "strictNullChecks": true,        // Enforce null/undefined handling
    "strictFunctionTypes": true,     // Strict function type checking
    "noImplicitAny": true,           // No implicit 'any' types
    "noImplicitThis": true,          // 'this' must be explicitly typed
    "noUnusedLocals": true,          // Error on unused variables
    "noUnusedParameters": true,      // Error on unused parameters
    "noImplicitReturns": true,       // All code paths must return
    "noFallthroughCasesInSwitch": true
  }
}

Handling Undefined Responses

With strict null checks, handle undefined responses explicitly:

// Bad - will error in strict mode
const response = handler({ method: 'GET', path: '/users' })
console.log(response.body)  // Error: Object is possibly 'undefined'
 
// Good - explicit check
const response = handler({ method: 'GET', path: '/users' })
if (response) {
  console.log(response.body)  // Safe
}
 
// Good - optional chaining
console.log(response?.body)
 
// Good - nullish coalescing
const users = response?.body ?? []

Type-Safe Testing Patterns

Vitest with TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import { readScenario } from '@mockmaster/cli'
import { createReplayHandler, type ReplayHandler } from '@mockmaster/msw-adapter'
 
interface User {
  id: number
  name: string
  email: string
}
 
describe('User API', () => {
  let handler: ReplayHandler
 
  beforeEach(async () => {
    const scenario = await readScenario('./scenarios', 'user-api')
    handler = createReplayHandler(scenario)
  })
 
  it('returns typed user data', () => {
    const response = handler({ method: 'GET', path: '/users/1' })
 
    expect(response).toBeDefined()
 
    // Type assertion for test
    const user = response!.body as User
 
    // Now TypeScript knows the shape
    expect(user.id).toBe(1)
    expect(user.name).toMatch(/\w+/)
    expect(user.email).toContain('@')
  })
 
  it('handles array responses', () => {
    const response = handler({ method: 'GET', path: '/users' })
 
    expect(response).toBeDefined()
 
    const users = response!.body as User[]
 
    expect(Array.isArray(users)).toBe(true)
    expect(users[0]).toHaveProperty('id')
    expect(users[0]).toHaveProperty('name')
  })
})

Jest with TypeScript

import { readScenario } from '@mockmaster/cli'
import { createReplayHandler } from '@mockmaster/msw-adapter'
 
interface Product {
  id: number
  name: string
  price: number
  inStock: boolean
}
 
describe('Product API', () => {
  let handler: ReturnType<typeof createReplayHandler>
 
  beforeEach(async () => {
    const scenario = await readScenario('./scenarios', 'products')
    handler = createReplayHandler(scenario)
  })
 
  test('fetches products with correct types', () => {
    const response = handler({ method: 'GET', path: '/products' })
 
    expect(response).toBeDefined()
 
    const products = response!.body as Product[]
 
    products.forEach(product => {
      expect(typeof product.id).toBe('number')
      expect(typeof product.name).toBe('string')
      expect(typeof product.price).toBe('number')
      expect(typeof product.inStock).toBe('boolean')
    })
  })
})

Advanced Patterns

Builder Pattern with Types

class TypedScenarioBuilder<T> {
  private scenario: Scenario
 
  constructor(name: string, description: string) {
    this.scenario = createScenario(name, description)
  }
 
  addRecording(
    method: string,
    path: string,
    status: number,
    body: T
  ): this {
    const recording = createRecording<T>(
      { method, url: `https://api.example.com${path}`, path, timestamp: Date.now() },
      { status, body, timestamp: Date.now() }
    )
 
    this.scenario = addRecordingToScenario(this.scenario, recording)
    return this
  }
 
  build(): Scenario {
    return this.scenario
  }
}
 
// Usage
interface User {
  id: number
  name: string
}
 
const scenario = new TypedScenarioBuilder<User>('users', 'User API')
  .addRecording('GET', '/users/1', 200, { id: 1, name: 'Alice' })
  .addRecording('GET', '/users/2', 200, { id: 2, name: 'Bob' })
  .build()

Generic Factory Functions

import { Factory, FactoryDefinition } from '@mockmaster/data'
 
// Generic factory creator
function createApiFactory<T extends Record<string, any>>(
  name: string,
  definition: FactoryDefinition<T>
): Factory<T> {
  return defineFactory<T>(name, definition)
}
 
// Use with specific types
interface Post {
  id: number
  title: string
  body: string
  authorId: number
}
 
const postFactory = createApiFactory<Post>('post', {
  id: (ctx) => ctx.sequence('post'),
  title: () => fake.lorem.sentence(),
  body: () => fake.lorem.paragraphs(3),
  authorId: () => fake.number.int({ min: 1, max: 100 })
})
 
const post: Post = build(postFactory)

Type Exports

MockMaster exports all necessary types:

// Core types
import type {
  PathMatcher,
  PathParams,
  MatchResult
} from '@mockmaster/core'
 
// Data types
import type {
  Factory,
  FactoryDefinition,
  FactoryContext,
  BuildOptions
} from '@mockmaster/data'
 
// MSW Adapter types
import type {
  Scenario,
  Recording,
  RequestDetails,
  ResponseDetails,
  ReplayHandler
} from '@mockmaster/msw-adapter'
 
// OpenAPI types
import type {
  OpenAPISpec,
  SchemaObject,
  PathItemObject,
  OperationObject,
  ResponseObject
} from '@mockmaster/openapi'

Best Practices

1. Define Interfaces First

// Define your data shapes
interface User {
  id: number
  name: string
  email: string
}
 
// Then create factories/recordings
const userFactory = defineFactory<User>(...)
const userRecording = createRecording<User>(...)

2. Use Type Guards

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj
  )
}

3. Avoid any

// Bad
const data: any = response.body
 
// Good
const data = response.body as User
// or
const data: User = response.body

4. Use Generics

// Reusable typed functions
function createTestHandler<T>(scenarioName: string): ReplayHandler {
  const scenario = await readScenario('./scenarios', scenarioName)
  return createReplayHandler(scenario)
}

Common TypeScript Errors

Error: Type ‘undefined’ is not assignable

// Problem
const response = handler({ method: 'GET', path: '/users' })
const users = response.body  // Error: Object is possibly 'undefined'
 
// Solution 1: Type guard
if (response) {
  const users = response.body  // OK
}
 
// Solution 2: Non-null assertion (use carefully)
const users = response!.body
 
// Solution 3: Optional chaining
const users = response?.body

Error: Property does not exist on type

// Problem
const user = response.body
console.log(user.name)  // Error: Property 'name' does not exist
 
// Solution: Type assertion
const user = response.body as User
console.log(user.name)  // OK

Key Takeaways

  • ✅ Type your factories with interfaces for full type safety
  • ✅ Use generics for reusable, type-safe code
  • ✅ Leverage type guards for safe runtime checks
  • ✅ Enable strict mode in tsconfig.json
  • ✅ Export and reuse type definitions
  • ✅ Use TypeScript’s inference when possible
  • ✅ Handle undefined responses explicitly

MockMaster + TypeScript = Type-safe, confident testing!