Shared Zod schemas
Validate input on every surface.
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
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
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
import { ProductSchema } from './product'// A product with admin-only extrasexport const ProductAdminSchema = ProductSchema.extend({cost_cents: z.number().int().nonnegative(),internal_notes: z.string(),})// A list responseexport 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.
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
Try it
Build a shared, validated form across two surfaces:
- Use
CreateProductSchemain a web form at/admin/products/new. - Use the same
CreateProductSchemain a desktop form (Wails) at "Add Product". - Both must show the same field errors for the same bad input (try: missing name, negative price, currency of length 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