Skip to content
SaaS4Builders
Frontend

Validation

Runtime validation with Zod schemas, the Resource-to-Zod pipeline, case transformation, and how schemas are structured alongside types.

Every API response in SaaS4Builders is validated at runtime using Zod schemas. This catches contract violations the moment they happen — not when a user triggers a bug in production. Schemas are the single source of truth for frontend types: all TypeScript types are inferred from Zod with z.infer<>.


The Validation Pipeline

Backend Resource (snake_case JSON)
    ↓
API Client (toCamelCase transform)
    ↓
API Module (Zod .parse())
    ↓
Composable / Store (typed, validated data)
  1. The backend returns snake_case JSON via Laravel Resources
  2. The API client automatically transforms keys to camelCase
  3. The API module validates the transformed data with a Zod schema
  4. The composable receives typed, validated data

If the backend changes a field name or type, the Zod .parse() call throws immediately — you see the error in development, not in production.


Case Transformation

The backend API uses snake_case keys (Laravel convention). The frontend uses camelCase (TypeScript convention). The API client handles the conversion automatically:

frontend/common/utils/_lib/caseTransform.ts
/**
 * Deep transform object keys from snake_case to camelCase.
 */
export function toCamelCase<T>(obj: unknown): T {
  if (obj === null || obj === undefined) return obj as T

  if (Array.isArray(obj)) {
    return obj.map((item) => toCamelCase(item)) as T
  }

  if (isPlainObject(obj)) {
    const result: Record<string, unknown> = {}
    for (const key of Object.keys(obj)) {
      const camelKey = snakeToCamel(key)
      result[camelKey] = toCamelCase(obj[key])
    }
    return result as T
  }

  return obj as T
}

/**
 * Deep transform object keys from camelCase to snake_case.
 */
export function toSnakeCase<T>(obj: unknown): T {
  // Same recursive structure, calls camelToSnake on keys
}

The transformation is deep — it handles nested objects, arrays, and preserves Dates and primitives. It happens inside useApiClient():

  • Outgoing requests: toSnakeCase(body) before sending
  • Incoming responses: toCamelCase(response) after receiving
All Zod schemas use camelCase field names because they validate the data after transformation. The schema priceCents maps to the API's price_cents.

Schema Structure

Each feature has a schemas.ts file that defines all Zod schemas for that domain. Schemas are organized by type: enums, resources, inputs, and constants.

Real Example: Billing Schemas

frontend/features/core/docs/billing/schemas.ts
import { z } from 'zod'
import { moneySchema } from '@common/types/money'
import { paginationMetaSchema, paginatedSchema } from '@common/api/schema'

// ─── Enums ───────────────────────────────────────────────────────────

export const subscriptionStatusSchema = z.enum([
  'active', 'trialing', 'past_due', 'canceled',
  'unpaid', 'paused', 'incomplete', 'incomplete_expired',
])

export const pricingTypeSchema = z.enum(['flat', 'seat', 'usage'])

export const intervalUnitSchema = z.enum(['day', 'week', 'month', 'year'])

// ─── Resource Schemas ────────────────────────────────────────────────

export const billingPlanSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  slug: z.string().optional(),
  description: z.string().nullable().optional(),
  pricingType: pricingTypeSchema,
  intervalUnit: intervalUnitSchema,
  intervalCount: z.number().int().positive(),
  trialDays: z.number().int().min(0),
  prices: z.array(z.object({
    currency: z.string(),
    priceCents: z.number().int().min(0),
  })).optional(),
  features: z.array(planFeatureSchema).optional(),
  eligibility: planEligibilitySchema.optional(),
})

export const subscriptionSchema = z.object({
  id: z.string().uuid(),
  status: subscriptionStatusSchema,
  plan: billingPlanSchema.nullable().optional(),
  currency: z.string().length(3),
  priceCents: z.number().int().min(0),
  quantity: z.number().int().positive(),
  intervalUnit: intervalUnitSchema,
  intervalCount: z.number().int().positive(),
  currentPeriodStart: z.string().datetime().nullable(),
  currentPeriodEnd: z.string().datetime().nullable(),
  trialEndsAt: z.string().datetime().nullable(),
  cancelAtPeriodEnd: z.boolean(),
  canceledAt: z.string().datetime().nullable(),
  cancellationReason: z.string().nullable(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
})

// ─── Input Schemas ───────────────────────────────────────────────────

export const checkoutInputSchema = z.object({
  planId: z.string().uuid(),
  currency: z.string().length(3),
  successUrl: z.string().url(),
  cancelUrl: z.string().url(),
  quantity: z.number().int().min(1).optional(),
})

// ─── Status Constants ────────────────────────────────────────────────

export const ACTIVE_STATUSES = ['active', 'trialing'] as const
export const CANCELLABLE_STATUSES = ['active', 'trialing', 'past_due'] as const
export const PROBLEM_STATUSES = ['past_due', 'unpaid'] as const

export const SUBSCRIPTION_STATUS_COLORS: Record<string, string> = {
  active: 'success', trialing: 'info', past_due: 'warning',
  canceled: 'neutral', unpaid: 'error',
}

Key Schema Conventions

ConventionRuleExample
Field namescamelCase (after API transform)priceCents, intervalUnit
Money fields{ amountCents: number, currency: string }moneySchema
DatesISO-8601 stringz.string().datetime() or z.string()
IDsUUID stringsz.string().uuid()
EnumsLowercase snake_case valuesz.enum(['flat', 'seat', 'usage'])
Nullable fields.nullable()description: z.string().nullable()
Optional relations.optional()features: z.array(...).optional()

Type Derivation

Types are never defined manually for API data. They are always inferred from Zod schemas using z.infer<>:

frontend/features/core/docs/billing/types.ts
import type { z } from 'zod'
import type {
  subscriptionStatusSchema,
  billingPlanSchema,
  subscriptionSchema,
  checkoutInputSchema,
  // ... more schemas
} from './schemas'

// Enum types
export type SubscriptionStatus = z.infer<typeof subscriptionStatusSchema>
export type PricingType = z.infer<typeof pricingTypeSchema>

// Resource types
export type BillingPlan = z.infer<typeof billingPlanSchema>
export type Subscription = z.infer<typeof subscriptionSchema>
export type Invoice = z.infer<typeof invoiceSchema>

// Input types
export type CheckoutInput = z.infer<typeof checkoutInputSchema>
export type CancelSubscriptionInput = z.infer<typeof cancelSubscriptionInputSchema>

This ensures types and runtime validation are always in sync. If you change the schema, the type updates automatically.


Shared Schemas

Common schemas that multiple features use live in common/:

frontend/common/api/schema.ts
import { z } from 'zod'

/**
 * Pagination meta following CONTRACTS.md §2
 */
export const paginationMetaSchema = z.object({
  currentPage: z.number(),
  lastPage: z.number(),
  perPage: z.number(),
  total: z.number(),
})

/**
 * Factory for paginated responses
 */
export function paginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
  return z.object({
    data: z.array(itemSchema),
    meta: paginationMetaSchema,
  })
}

/**
 * Standard error response
 */
export const errorResponseSchema = z.object({
  message: z.string(),
  errors: z.record(z.array(z.string())).optional(),
})
frontend/common/types/money.ts
export const moneySchema = z.object({
  amountCents: z.number().int(),
  currency: z.string().length(3),
})

export type Money = z.infer<typeof moneySchema>

Features import these shared schemas:

import { moneySchema } from '@common/types/money'
import { paginatedSchema } from '@common/api/schema'

// Use in feature schemas
export const paginatedInvoicesSchema = paginatedSchema(invoiceSchema)

Validation in API Modules

API modules validate responses immediately after receiving them. If the response does not match the schema, a ZodError is thrown:

frontend/features/core/team/api/team.api.ts
async function listMembers(): Promise<TeamMember[]> {
  const response = await get<{ data: unknown[] }>(
    tenantPath('/team/members')
  )
  return teamMemberListSchema.parse(response).data
}

async function getStats(): Promise<TeamStats> {
  const response = await get<{ data: unknown }>(
    tenantPath('/team/stats')
  )
  return teamStatsSchema.parse(response.data)
}

The pattern is consistent:

  1. Call the API via useApiClient() (which handles auth, CSRF, case transforms)
  2. Parse the response with the Zod schema (.parse())
  3. Return the typed, validated result

Input validation follows the same pattern — validate before sending:

async function createInvitation(input: CreateInvitationInput): Promise<Invitation> {
  const validated = createInvitationInputSchema.parse(input)
  const response = await post<{ data: unknown }>(
    tenantPath('/team/invitations'),
    validated
  )
  return invitationSchema.parse(response.data)
}

Adding a New Schema

When you add a new API resource, follow this checklist:

  1. Add the schema to schemas.ts with camelCase field names
  2. Add the type to types.ts using z.infer<typeof schema>
  3. Export both from index.ts
  4. Use .parse() in the API module to validate responses
  5. Reference backend sources in JSDoc comments:
/**
 * Subscription status enum matching backend SubscriptionStatus
 * @see Domain/Billing/Enums/SubscriptionStatus.php
 */
export const subscriptionStatusSchema = z.enum([
  'active', 'trialing', 'past_due', // ...
])

What's Next