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
Recommended architecture (monorepo)
Keep contracts in a shared package:
apps/
api/
web/
packages/
contracts/Rules:
contractspackage 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.
Recommended naming convention
CreateUserInputSchemaUpdateUserInputSchemaUserResponseSchema
Bottom line
“Share types with frontend” should mean:
- one source of truth
- compile-time and runtime alignment
- explicit contracts for requests and responses