Shared Zod schemas

Validate input on every surface.

7 minmedium

TypeScript types live at compile time; fetch returns unknown at runtime. Zod schemas bridge the two β€” they describe the shape, validate it, AND infer a TS type. Shared across all four surfaces, they are your safety net at every boundary.

What grit sync generates for Zod

packages/shared/zod/product.ts (generated)
import { z } from 'zod'
export const ProductSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
slug: z.string().min(1),
price_cents: z.number().int().nonnegative(),
currency: z.string().length(3),
in_stock: z.boolean(),
tags: z.string(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
})
export const CreateProductSchema = ProductSchema.omit({
id: true,
created_at: true,
updated_at: true,
}).partial({
currency: true,
in_stock: true,
tags: true,
})
export type Product = z.infer<typeof ProductSchema>
export type CreateProductInput = z.infer<typeof CreateProductSchema>

Three things from one declaration: a runtime validator, a TypeScript type, AND a derived input schema for create endpoints. Single source of truth.

Where you use the schemas

1. Forms β€” on every surface

apps/web (and apps/admin, apps/mobile, apps/desktop)
import { CreateProductSchema } from '@my-saas/shared'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm({
resolver: zodResolver(CreateProductSchema),
defaultValues: { name: '', slug: '', price_cents: 0 },
})

Same hook, same schema, every surface. The form errors, labels, even autofill behave consistently across web, admin, mobile (react-hook-form works in Expo too), and desktop.

2. API responses β€” validate what you receive

const json = await res.json()
const product = ProductSchema.parse(json.data)
// product is now a typed, validated Product. If the API drifts, you crash HERE
// with a meaningful error β€” not 5 components later when you try product.namE.toLowerCase()

3. The Go API β€” validating input

Go doesn't use Zod, but Grit's generators emit Gin binding tags from the same source (the Go struct's binding tags). So the rules stay in lockstep:

type CreateProductInput struct {
Name string `json:"name" binding:"required,min=1"`
Slug string `json:"slug" binding:"required,min=1"`
PriceCents int `json:"price_cents" binding:"gte=0"`
Currency string `json:"currency" binding:"omitempty,len=3"`
}

Zod on the frontend, Gin on the backend, both agree Name is required min-1 chars. Drift here is the source of "but it worked in the form, why does the API reject it?" bugs. grit sync keeps them aligned.

Composing schemas

packages/shared/zod/extra.ts
import { ProductSchema } from './product'
// A product with admin-only extras
export const ProductAdminSchema = ProductSchema.extend({
cost_cents: z.number().int().nonnegative(),
internal_notes: z.string(),
})
// A list response
export const ProductListSchema = z.object({
data: z.array(ProductSchema),
meta: z.object({
total: z.number(),
page: z.number(),
page_size: z.number(),
}),
})

Schemas compose like Lego. Same primitives, same import path, every surface.

Don't parse twice. If the API client validates the response, the components that consume it don't need to validate again. Validate at the boundary; trust your types inside the system.

The cost of NOT sharing Zod

Without a shared schema, each surface re-declares validation rules:

  • Web: z.string().min(1) for name
  • Admin: z.string() β€” forgot the min
  • Mobile: regex check
  • Desktop: no validation, server rejects, user confused

And one of them will be wrong. Shared Zod kills that whole class of inconsistency.

Quick check

Why do the API responses need ZodSchema.parse() if TypeScript already knows the response shape?

Try it

Build a shared, validated form across two surfaces:

  1. Use CreateProductSchema in a web form at /admin/products/new.
  2. Use the same CreateProductSchema in a desktop form (Wails) at "Add Product".
  3. Both must show the same field errors for the same bad input (try: missing name, negative price, currency of length 4).
  4. Submit both β€” the API accepts the valid one, rejects the bad one with a 422 + matching error keys.

The point: same schema, same UX, no duplicate validation logic. If a rule changes, you edit it ONCE.

What's next

Chapter 3 β€” Building a feature across all three. Time to use the shared types + schemas for something real. We'll pick a feature, then implement it on web, mobile, and desktop, lesson by lesson.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β€” suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub