Skip to content

Instantly share code, notes, and snippets.

@bogdan
Created June 6, 2026 18:28
Show Gist options
  • Select an option

  • Save bogdan/1cf82973898060605c8c92c57382e259 to your computer and use it in GitHub Desktop.

Select an option

Save bogdan/1cf82973898060605c8c92c57382e259 to your computer and use it in GitHub Desktop.

How We Keep Our API and Frontend Automatically In Sync

Our backend is a Ruby REST API built with Grape. Our frontend is TypeScript. Keeping them in sync manually is a recipe for silent drift — a renamed field here, a removed param there, and suddenly the frontend is sending requests the server no longer understands or rendering fields that no longer exist.

This post describes the pipeline we built so that the server code is the single source of truth, type mismatches are caught before they ship, and backward incompatibilities surface as compiler errors rather than production bugs. The specific tools are Ruby-flavored, but the pattern is language-agnostic and maps directly to FastAPI, NestJS, or any framework that can generate an OpenAPI spec from code.


The Pipeline at a Glance

Server endpoint definitions  (typed params + typed response shapes)
        ↓  generate OpenAPI spec from live code
OpenAPI 3.1 JSON  (committed to the repository)
        ↓  code-generate a TypeScript client from the spec
Typed TypeScript SDK  (committed to the repository)
        ↓  tsc --noEmit
Compilation passes → frontend is consistent with the server

Every step is either tested or enforced by the type system. Nothing relies on a developer remembering to update anything.


Step 1: Define the endpoint once, with types

In Grape, each endpoint declares its accepted parameters and its response shape in one place:

desc 'List photos',
  nickname: 'get_photos',
  success: { code: 200, model: PhotoEntity }
params do
  optional :status,        type: String,  values: %w[pending approved rejected]
  optional :uploaded_after, type: Date
  optional :page,          type: Integer, default: 1
  optional :per_page,      type: Integer, default: 50
end
get '/photos' do
  # ...
end

Response shapes are declared as Grape entities — serializer classes where each field carries its type and nullability as metadata:

class PhotoEntity < Grape::Entity
  expose :id,          documentation: { type: 'Integer' }
  expose :url,         documentation: { type: 'String', format: 'uri' }
  expose :uploaded_at, documentation: { type: 'DateTime', format: 'date-time' }
  expose :caption,     documentation: { type: 'String', x: { nullable: true } }
end

Step 2: Generate the OpenAPI spec from the live code

A single command introspects the running API classes and writes the spec:

# generate the spec
bundle exec rails runner \
  "File.write('api.spec.json', JSON.pretty_generate(GrapeOAS.generate(app: RootAPI, schema_type: :oas31)))"

# regenerate the TypeScript client from the new spec
yarn openapi-ts

# shorthand for both steps together
yarn api:update

The grape_oas gem traverses the live API class — the same object the server uses to route requests — and emits a valid OpenAPI 3.1 document. No separate YAML file to maintain.

The spec file is committed to the repository. This is deliberate: every API change produces a diff in that file, making the change reviewable alongside the code that caused it.

The equivalent in other ecosystems:

  • FastAPIapp.openapi() returns the spec as a dict; dump it with json.dump
  • NestJSSwaggerModule.createDocument(app, config) at build time
  • Go (chi/gin) — swaggo annotations generate the spec via swag init

Step 3: Generate the TypeScript client from the spec

@hey-api/openapi-ts reads the spec and generates a fully-typed client:

// openapi-ts.config.ts
export default defineConfig({
  input: 'api.spec.json',
  output: { path: 'src/api' },
  plugins: [
    '@hey-api/typescript',
    { name: '@hey-api/transformers', dates: true },
    '@hey-api/client-fetch',
    { name: '@hey-api/sdk', transformer: true },
    '@tanstack/svelte-query',
  ],
});

How Inconsistencies Are Caught

The metadata on each entity field — type, nullability, array-ness, format — is what gets written into the OpenAPI spec. If it lies, the spec lies, and the generated TypeScript types lie. We test that it doesn't by running each entity against a real database object:

class PhotoEntityTest < ApplicationEntityTestCase
  def test_types
    photo = create(:photo)
    assert_entity_documentation(PhotoEntity, photo)
  end
end

assert_entity_documentation iterates every exposed field and checks:

  • Type declared — every field must have a type: in its documentation. No undocumented fields sneak into the spec.
  • Nullability correct — if a field is not marked nullable: true, the real serialized value must never be nil.
  • Array-ness matches — a field declared as an array must serialize to an array; a scalar field must not.
  • Format validatesformat: "uri" must parse as a valid URL; format: "date-time" must be a valid ISO-8601 string or a time object.

If a developer adds a new field to an entity but forgets the documentation: annotation, or marks a field non-nullable while the code can return nil, the test fails before the spec is ever generated.


How Backward Incompatibilities Are Caught

The committed spec as a visible diff

Because api.spec.json is committed, any change to the API surface — removing a field, renaming a param, changing a type, dropping an endpoint — produces a visible file diff in the pull request. Reviewers see the contract change alongside the code change. No archaeology, no "wait, when did this field disappear?"

TypeScript compilation as the final gate

After yarn api:update, if any generated type changed in a way that breaks existing frontend code, yarn tsc --noEmit fails. We run this as an automated test in CI:

class TypeScriptCompilationTest < ActiveSupport::TestCase
  def test_compiles
    output, status = Open3.capture2e('yarn', 'tsc', '--noEmit', '--pretty', 'false')
    assert status.success?, "TypeScript compilation failed:\n#{output}"
  end
end

This is the backstop. A developer might update the Ruby endpoint, run yarn api:update, and then discover that getPhotos() now returns a shape that the rest of the frontend doesn't expect. The compiler reports every broken call site by file and line — no staging deploy, no manual testing, no runtime surprises.

The full chain of enforcement:

What changed Where it's caught
Entity field added without type annotation Entity documentation test
Entity field declared non-nullable but returns nil Entity documentation test
Response field removed or renamed tsc fails at every call site
Param type changed incompatibly tsc fails at every call site
Endpoint nickname/operationId changed tsc fails (function was renamed)
Spec not regenerated after API change Spec file diff missing from PR

Summary

The system works because each layer is tested against the layer below it:

  1. Endpoint definitions are the single source of truth — params, types, and response shapes live in one place.
  2. Spec generation is a command, not a human task — the live code produces the spec automatically.
  3. Client generation is deterministic — the same spec always produces the same TypeScript.
  4. Entity tests verify that the annotations match what the code actually serializes.
  5. TypeScript compilation verifies that the frontend matches the contract.

The result: a developer changes an endpoint in Ruby, runs one command, and either the tests tell them their annotations are wrong, or the TypeScript compiler tells them which frontend call sites need updating. No cross-team coordination, no stale documentation, no runtime surprises.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment