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.
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.
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
# ...
endResponse 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 } }
endA 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:updateThe 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:
- FastAPI —
app.openapi()returns the spec as a dict; dump it withjson.dump - NestJS —
SwaggerModule.createDocument(app, config)at build time - Go (chi/gin) — swaggo annotations generate the spec via
swag init
@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',
],
});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
endassert_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 benil. - Array-ness matches — a field declared as an array must serialize to an array; a scalar field must not.
- Format validates —
format: "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.
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?"
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
endThis 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 |
The system works because each layer is tested against the layer below it:
- Endpoint definitions are the single source of truth — params, types, and response shapes live in one place.
- Spec generation is a command, not a human task — the live code produces the spec automatically.
- Client generation is deterministic — the same spec always produces the same TypeScript.
- Entity tests verify that the annotations match what the code actually serializes.
- 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.