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

  1. 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.Time and *time.Time as string.
      • Handles pointers to primitives (e.g., *int, *string) as their base type.
      • Correctly sets the required array for struct fields: non-pointer, non-omitempty fields are required; pointer and omitempty fields are optional.
  2. Frontend Makefile automation

    • Our frontend Makefile includes a generate-types target that:

      • Fetches the OpenAPI schema from the running backend.
      • Uses openapi-typescript to generate src/types/backend.d.ts.
      • Runs a script to generate named type exports in src/types/index.d.ts for 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 --noEmit to type-check the codebase.
      • Fails fast if there is a contract mismatch.
  3. Dev workflow

    • The dev target depends on generate-types, ensuring our types are always up-to-date and type-safe before the dev server starts.

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

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 Server struct’s routes slice, including:

    • Path, method, summary
    • The Go reflect.Type of 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.schemas in 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 omitempty tag, 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.
  • 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.