grit sync — multi-surface

One Go model, three TS frontends.

7 minmedium

With four frontends, type drift is the silent killer. Web thinks price is a number; mobile thinks it's a string; desktop forgot to update after the API renamed it. Cue the production bug. grit sync exists to kill that whole category.

One Go model, four frontends

grit sync flow

apps/api/internal/models/product.go │ │ grit sync ▼ packages/shared/types/product.ts │ ┌────────┼────────┬────────┐ │ │ │ │ ▼ ▼ ▼ ▼ web admin mobile desktop
One command updates the type for every frontend at once.

The Go model is the source of truth

apps/api/internal/models/product.go
package models
import "time"
type Product struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex" json:"slug"`
PriceCents int `gorm:"not null" json:"price_cents"`
Currency string `gorm:"default:USD" json:"currency"`
InStock bool `json:"in_stock"`
Tags string `gorm:"type:text" json:"tags"` // CSV
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

Run grit sync

grit sync
# scans apps/api/internal/models/*.go
# writes packages/shared/types/*.ts
# writes packages/shared/zod/*.ts

What gets generated

packages/shared/types/product.ts (generated — do not edit)
// Generated by grit sync. Do not edit.
export interface Product {
id: number
name: string
slug: string
price_cents: number
currency: string
in_stock: boolean
tags: string
created_at: string
updated_at: string
}
export interface CreateProductInput {
name: string
slug: string
price_cents: number
currency?: string
in_stock?: boolean
tags?: string
}

Notice: CreateProductInput drops the server-managed fields (ID, timestamps) and makes defaulted-fields optional. That's what you POST to /api/products.

The naming rule — snake vs. camel

Go convention is PriceCents; JSON convention is price_cents. The TS file mirrors the JSON tag, NOT the Go field name, because that's what comes off the wire.

If you prefer camelCase on the frontend, change the JSON tag in Go: json:"priceCents". The frontend type will update next sync. Pick one and stay consistent.

Don't hand-edit the generated files. A big comment at the top says so, but it's tempting on a Friday afternoon. Your edit will be wiped on the next sync. If you need a frontend-only field (e.g., a computed display value), put it in a separate type that extends the generated one.

Add the field once — all frontends gain it

Suppose marketing asks for a Featured badge. Steps:

  1. Add IsFeatured bool to the Go struct.
  2. grit sync.
  3. Web, admin, mobile, desktop — all four IDEs now show the field.
  4. Run a DB migration (grit generates one too) so the column exists.

Five seconds of typing, zero copies, zero drift.

What sync does NOT do

  • Doesn't generate API client code. The shared package gives you the types; each surface still calls fetch (web/admin/desktop) or the mobile API wrapper. Types make the calls safe, not automatic.
  • Doesn't touch your existing UI. Add a field, the type changes; whether the UI shows it is on you. TS will flag where you destructure the type but ignore the new field as a soft warning if you enable it.
  • Doesn't roll back a deleted Go field. Remove a field in Go, sync, and the TS shape becomes narrower. Any code referencing the old field now type-errors — which is the point.

Quick check

You add nickname to the Go User model and run grit sync. The web app immediately type-errors because UserCard.tsx tries to read user.nicname (typo). Why is this a good thing?

Try it

Feel the four-surface wave:

  1. Add IsFeatured bool `gorm:"default:false" json:"is_featured"` to the Product Go model.
  2. Run grit sync from the repo root.
  3. Open the generated packages/shared/types/product.ts — confirm is_featured is there.
  4. Run pnpm type-check at the root. Should pass (we're adding a field, not breaking existing code).
  5. Use product.is_featured in ONE component in each of web, admin, mobile, desktop — even just rendering "★" if true. Confirm autocomplete in all four IDEs.

What's next

Next lesson — Shared Zod schemas. Types tell you what shape data has; Zod validates that the runtime data actually matches that shape. Both sides of the input boundary.

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