Kadak

Frontend Contracts (Shared Types + Validation)

Define one contract layer shared by frontend and backend so types and runtime validation never drift.

Frontend Contracts (Shared Types + Validation)

Most teams say “shared types” when they actually need shared contracts.

A contract is stronger than a TypeScript type:

  • a compile-time shape (type)
  • a runtime validator (zod schema)
  • an explicit boundary (what is allowed in/out of an API)

Kadak gives you contract-ready validators from your table schema.

What is a contract?

Contract = the agreement between systems at API boundaries.

  • Request contract: payload your API accepts.
  • Response contract: payload your API returns.
  • Domain contract: internal shape between service layers.

If any of these drift, you get production bugs.

Why plain shared types are not enough

TypeScript types disappear at runtime. A client can still send invalid JSON.

You need runtime validation at the edge:

  • browser form input
  • API request body
  • integration events/webhooks

Keep contracts in a shared package:

apps/
  api/
  web/
packages/
  contracts/

Rules:

  • contracts package contains only schemas/types.
  • no framework imports (express, next, etc).
  • frontend and backend both import from contracts.

Build request contracts from Kadak validators

// packages/contracts/user.ts
import { z } from 'zod'
import { users } from '@api/schema'

export const createUserSchema = users
  .insertValidator()
  .omit({ id: true, createdAt: true, updatedAt: true, deletedAt: true })

export type CreateUserInput = z.infer<typeof createUserSchema>

Use the same contract in frontend

import { createUserSchema, type CreateUserInput } from '@contracts/user'

function toRequestBody(formValues: unknown): CreateUserInput {
  const parsed = createUserSchema.safeParse(formValues)
  if (!parsed.success) throw parsed.error
  return parsed.data
}

Use the same contract in backend

import { createUserSchema } from '@contracts/user'

const parsed = createUserSchema.safeParse(req.body)
if (!parsed.success) {
  return res.status(400).json(parsed.error.flatten())
}
await db.users.insert(parsed.data)

Response contracts (important and often skipped)

Do not return raw DB rows directly when your API shape differs from storage shape.

import { z } from 'zod'

export const userResponseSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string(),
  isActive: z.boolean(),
})

export type UserResponse = z.infer<typeof userResponseSchema>

Boundaries to keep strict

  • Exclude server-managed fields from create/update inputs (id, timestamps, soft-delete fields).
  • Exclude internal-only fields from API responses.
  • Version contracts for breaking payload changes.
  • CreateUserInputSchema
  • UpdateUserInputSchema
  • UserResponseSchema

Bottom line

“Share types with frontend” should mean:

  • one source of truth
  • compile-time and runtime alignment
  • explicit contracts for requests and responses

On this page