Automatic TypeScript Generation from Backend Schema
Purpose
Ensure our frontend TypeScript types always match our backend API contract by automatically generating types from the backend’s OpenAPI schema, with correct required/optional fields, and type fidelity for Go primitives, pointers, and time.Time.
Pattern Steps
-
Backend exposes OpenAPI schema
-
Our backend provides an endpoint (e.g.,
/or/openapi.json) that returns the OpenAPI schema. -
The schema generator:
- Adds all unique request/response types as named schemas in
components.schemas. - Handles Go
time.Timeand*time.Timeasstring. - Handles pointers to primitives (e.g.,
*int,*string) as their base type. - Correctly sets the
requiredarray for struct fields: non-pointer, non-omitempty fields are required; pointer andomitemptyfields are optional.
- Adds all unique request/response types as named schemas in
-
-
Frontend Makefile automation
-
Our frontend Makefile includes a
generate-typestarget that:- Fetches the OpenAPI schema from the running backend.
- Uses
openapi-typescriptto generatesrc/types/backend.d.ts. - Runs a script to generate named type exports in
src/types/index.d.tsfor all backend models. This script can be disabled or only run once; we may prefer to just export one contract type at a time manually as we do the conversion on the frontend instead of dumping out hundreds that are unused. - Runs
tsc --noEmitto type-check the codebase. - Fails fast if there is a contract mismatch.
-
-
Dev workflow
- The
devtarget depends ongenerate-types, ensuring our types are always up-to-date and type-safe before the dev server starts.
- The
Example Makefile Snippet
generate-types-and-run-backend: run-backend
mkdir -p web-app/src/types
@TEMP_SCHEMA_FILE=$$(mktemp) && \
curl -s http://localhost:8080/ > $$TEMP_SCHEMA_FILE && \
npx openapi-typescript $$TEMP_SCHEMA_FILE --output web-app/src/types/backend.d.ts && \
rm $$TEMP_SCHEMA_FILE
cd web-app && node scripts/generate-type-exports.js
cd web-app && npx tsc --noEmit
@echo "✅ TypeScript types generated and type-check passed!"
Example Go Schema Generation Logic
A simplified but representative version of the core schema generation logic:
func generateSchemaFromType(t reflect.Type) Schema {
if t == nil {
return Schema{Type: "object"}
}
// If pointer, dereference and use the underlying type's schema
if t.Kind() == reflect.Ptr {
return generateSchemaFromType(t.Elem())
}
// Treat time.Time as string
if t == reflect.TypeOf(time.Time{}) {
return Schema{Type: "string"}
}
switch t.Kind() {
case reflect.Struct:
properties := make(map[string]Schema)
var required []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" {
continue
}
parts := strings.Split(jsonTag, ",")
name := parts[0]
if name == "-" || name == "" {
continue
}
// Recursively generate schema for each field
properties[name] = generateSchemaFromType(field.Type)
// Mark as required if not pointer and not omitempty
isPointer := field.Type.Kind() == reflect.Ptr
hasOmitEmpty := false
for _, tag := range parts[1:] {
if tag == "omitempty" {
hasOmitEmpty = true
break
}
}
if !isPointer && !hasOmitEmpty {
required = append(required, name)
}
}
return Schema{
Type: "object",
Properties: properties,
Required: required,
}
case reflect.Slice:
return Schema{
Type: "array",
Properties: map[string]Schema{
"items": generateSchemaFromType(t.Elem()),
},
}
case reflect.String:
return Schema{Type: "string"}
case reflect.Int, reflect.Int64:
return Schema{Type: "integer"}
case reflect.Bool:
return Schema{Type: "boolean"}
default:
return Schema{Type: "object"}
}
}
This function ensures that pointers, time.Time, required/optional fields, and nested types are all handled in a way that produces a faithful OpenAPI schema for TypeScript generation.
Example TypeScript Usage
import type { MetricResponse, HabitResponse } from '@/types';
Benefits
- Single source of truth: Our backend schema drives our frontend types.
- Automatic contract validation: Type errors are caught before the dev server starts.
- No manual sync: Eliminates human error and reduces maintenance overhead.
- Accurate types: Required/optional fields and time/pointer types are faithfully represented.
- Frequent regeneration built into usual dev flow: Regenerate the types on startup of the backend by chaining Makefile targets so we don’t have to remember to do it.
Tooling
- openapi-typescript
- TypeScript
- curl
- Custom Go OpenAPI schema generator
- Node.js script for type re-exports
Notes
- Ensure our backend is running before invoking
generate-types. - Adjust schema endpoint and type file location as needed for our project structure.
- This pattern is robust to changes in backend models and will keep frontend types in sync automatically.
How Route Registration Enables Automatic OpenAPI Schema Generation
1. Route Registration: The Foundation
Every API endpoint in our backend is registered using a generic, type-safe system. We define routes using helpers like NewRoute and NewSecureRoute, which capture both the request and response types at compile time:
AddRoute(s, NewRoute[MagicLinkRequest](
"/auth/magic-link",
http.MethodPost,
"Request a magic link for authentication",
s.requestMagicLink,
))
This pattern is used for all routes, including public, authenticated, and feature-specific endpoints (see RegisterRoutes and helpers like registerMetricsRoutes).
2. Capturing Type Metadata for Every Route
When a route is registered, the AddRoute function does two things:
-
It registers the handler with the HTTP server.
-
Crucially, it stores metadata about the route in our
Serverstruct’sroutesslice, including:- Path, method, summary
- The Go
reflect.Typeof the request and response types - Whether the route is secured
s.routes = append(s.routes, RouteMetadata{
Path: route.Path,
Method: route.Method,
Summary: route.Summary,
RequestType: reflect.TypeOf((*Req)(nil)).Elem(),
ResponseType: reflect.TypeOf((*Res)(nil)).Elem(),
Secured: route.Secured,
})
This means every route’s contract is captured in a machine-readable way at startup.
3. Schema Generation: Turning Types into OpenAPI
When the OpenAPI schema endpoint (/) is called, our server runs getOpenApiSchema. This function:
a. Collects All Unique Types
- Iterates over all registered routes and collects every unique request and response type.
uniqueTypes := map[string]reflect.Type{}
for _, route := range s.routes {
if route.RequestType != nil && route.RequestType.Name() != "" {
uniqueTypes[route.RequestType.Name()] = route.RequestType
}
if route.ResponseType != nil && route.ResponseType.Name() != "" {
uniqueTypes[route.ResponseType.Name()] = route.ResponseType
}
}
b. Generates Named Schemas
- For each unique type, it generates a named schema and adds it to
components.schemasin the OpenAPI output.
for name, typ := range uniqueTypes {
schema.Components.Schemas[name] = generateSchemaFromType(typ)
}
c. References Schemas in Paths
- When describing each route’s request and response, it references the named schema using
$ref(not inlining), so the OpenAPI spec is DRY and TypeScript generators can create plain interfaces.
func schemaOrRef(t reflect.Type) Schema {
if t == nil {
return Schema{Type: "object"}
}
if t.Name() != "" {
return Schema{Ref: "#/components/schemas/" + t.Name()}
}
return generateSchemaFromType(t)
}
4. Type Fidelity: Handling Pointers, time.Time, and Required Fields
Our schema generator (generateSchemaFromType) is designed to accurately reflect Go’s type system in OpenAPI:
-
Pointers: If a field is a pointer (e.g.,
*int), it is dereferenced and the schema for the base type is used. This means nullable fields are correctly represented.if t.Kind() == reflect.Ptr { return generateSchemaFromType(t.Elem()) } -
time.Time: Treated as a string in the schema, so date/time fields are correctly typed in TypeScript.
if t == reflect.TypeOf(time.Time{}) { return Schema{Type: "string"} } -
Required vs. Optional: For struct fields, if a field is not a pointer and does not have the
omitemptytag, it is marked as required in the schema. Otherwise, it is optional.isPointer := field.Type.Kind() == reflect.Ptr hasOmitEmpty := false for _, tag := range parts[1:] { if tag == "omitempty" { hasOmitEmpty = true break } } if !isPointer && !hasOmitEmpty { required = append(required, name) }
5. Recursive and Comprehensive
- Our schema generator is recursive: it walks through nested structs, slices, and arrays, ensuring all nested types are included and referenced.
- If a type is unknown or unsupported, it falls back to
{ type: "object" }to keep the schema valid.
6. Automatic, Always Up-to-Date
- Because route registration and type capture are tightly coupled, any new route or model we add is automatically included in the OpenAPI schema.
- Our frontend can always generate up-to-date TypeScript types from our backend’s schema, with no manual sync required.
7. Summary: The Full Picture
-
Routes are registered with type information.
- We can easily do this through our
BindRoutes()funcs.
- We can easily do this through our
-
Type metadata is stored for every route.
-
The OpenAPI schema endpoint introspects all routes and types, generating a complete, accurate schema.
- We may want to only expose this in local dev, not staging / prod.
-
Frontend tooling consumes this schema to generate TypeScript interfaces that are always in sync with our backend.
This design ensures our API contract is always explicit, machine-readable, and enforced across the stack.