Type Pipeline¶
VisionStudio maintains type synchronization between Go backend and TypeScript frontend through a Go-first type pipeline.
Pipeline Overview¶
Go structs (pkg/apitypes/types.go)
│
│ go generate ./pkg/apitypes
▼
JSON Schema (pkg/apitypes/schema/*.schema.json)
│
│ npm run generate:types
▼
Zod schemas (web/src/api/schemas.gen.ts)
│
│ z.infer<>
▼
TypeScript types (web/src/api/types.gen.ts)
Why Go-First?¶
- Single source of truth — Go types define the API contract
- Compile-time safety — Go catches type errors before runtime
- Auto-sync — Frontend types update automatically when backend changes
- No drift — Generated code can't get out of sync
Regenerating Types¶
After modifying pkg/apitypes/types.go:
# Generate JSON schemas from Go types
go generate ./pkg/apitypes
# Generate Zod/TypeScript from JSON schemas
cd web && npm run generate:types
JSON Naming Convention¶
API types use camelCase for JSON serialization to match JavaScript conventions:
// pkg/apitypes/types.go
type JudgeResult struct {
ID string `json:"id"`
InitiativeID string `json:"initiativeId"` // camelCase
SpecPath string `json:"specPath"` // camelCase
EvaluatedAt time.Time `json:"evaluatedAt"` // camelCase
}
Store types use snake_case for database compatibility:
// pkg/store/store.go
type JudgeResult struct {
ID string `json:"id"`
InitiativeID string `json:"initiative_id"` // snake_case
SpecPath string `json:"spec_path"` // snake_case
}
Type Conversion¶
API handlers convert between store and API types:
// cmd/visionstudio/api.go
func storeJudgeResultToAPI(r *store.JudgeResult) apitypes.JudgeResult {
return apitypes.JudgeResult{
ID: r.ID,
InitiativeID: r.InitiativeID, // Same value, different JSON tag
SpecPath: r.SpecPath,
// ...
}
}
Compat Layer¶
Generated TypeScript types have all fields optional (JSON Schema limitation). The compat layer normalizes these:
// web/src/api/compat.ts
// Generated type (all optional)
type GenJudgeResult = {
id?: string
initiativeId?: string
// ...
}
// Normalized type (required with defaults)
interface JudgeResult {
id: string
initiativeId: string
// ...
}
// Converter
function toJudgeResult(gen: GenJudgeResult): JudgeResult {
return {
id: gen.id ?? '',
initiativeId: gen.initiativeId ?? '',
// ...
}
}
Use converters in web/src/api/client.ts:
export async function getSpecs(): Promise<SpecsResponse> {
const raw = await fetchJSON<GenSpecsResponse>('/specs')
return toSpecsResponse(raw)
}
File Structure¶
pkg/apitypes/
├── types.go # Go types (source of truth)
├── gen/
│ └── main.go # Schema generator
└── schema/
├── JudgeResult.schema.json
├── SpecsResponse.schema.json
└── ...
web/
├── scripts/
│ └── generate-types.mjs # JSON Schema → Zod/TS
└── src/api/
├── schemas.gen.ts # Generated Zod schemas
├── types.gen.ts # Generated TypeScript types
├── compat.ts # Optional→required normalization
└── client.ts # API client with converters
Adding New Types¶
- Add Go struct to
pkg/apitypes/types.gowith camelCase JSON tags - Add type name to the generator list in
pkg/apitypes/gen/main.go - Run
go generate ./pkg/apitypes - Add type name to
schemaNamesarray inweb/scripts/generate-types.mjs - Run
cd web && npm run generate:types - If needed, add compat converter in
web/src/api/compat.ts - Use converter in
web/src/api/client.ts
Tools¶
| Tool | Purpose |
|---|---|
invopop/jsonschema |
Go → JSON Schema |
json-schema-to-zod |
JSON Schema → Zod |
zod |
Runtime validation + type inference |