TypeScript DSL for authoring OpenAPI 3.1+ specs. Uses TypeBox for JSON Schema with full type inference — you write TypeScript, not YAML.
Docs · Playground · Getting Started · API Reference
import { Api, named } from '@spec-spac/spac'
import { Type } from '@sinclair/typebox'
const Pet = named('Pet', Type.Object({ id: Type.String(), name: Type.String() }))
const api = new Api('3.1', 'Petstore', { version: '1.0.0' })
api.group('/pets', g => {
g.get('/').response(Type.Array(Pet)).summary('List all pets')
g.post('/').body(Pet).response(Pet).summary('Create a pet')
g.delete('/:id').params(Type.Object({ id: Type.String() })).respond(204)
})
const spec = api.emit() // valid OpenAPI 3.1 JSONNamed schemas automatically hoist to components.schemas as $refs. Groups inherit tags and security. Macros let you compose reusable route/group patterns.
npm install @spec-spac/spac @sinclair/typeboxCreate api.ts:
import { Api, named } from '@spec-spac/spac'
import { Type } from '@sinclair/typebox'
const User = named('User', Type.Object({
id: Type.String(),
name: Type.String(),
email: Type.String({ format: 'email' }),
}))
const api = new Api('3.1', 'My API', { version: '1.0.0' })
api.group('/users', g => {
g.tag('users')
g.get('/').response(Type.Array(User)).summary('List users')
g.get('/:id').params(Type.Object({ id: Type.String() })).response(User)
g.post('/').body(User).response(User).summary('Create user')
})
console.log(JSON.stringify(api.emit(), null, 2))npx tsx api.ts > openapi.jsonThat's it — openapi.json is a valid OpenAPI 3.1 document with User hoisted to components.schemas.
| Package | Install | What it does |
|---|---|---|
@spec-spac/spac |
npm i @spec-spac/spac |
Core library — define routes, groups, schemas, emit OpenAPI 3.1 JSON/YAML with source maps |
@spec-spac/from-openapi |
npm i -g @spec-spac/from-openapi |
CLI + library — reverse-generate spac TypeScript from an existing OpenAPI spec |
@spec-spac/from-openapi-biome |
npm i @spec-spac/from-openapi-biome |
Biome formatter plugin for the code generator |
@spec-spac/from-openapi-prettier |
npm i @spec-spac/from-openapi-prettier |
Prettier formatter plugin for the code generator |
Already have an OpenAPI spec? Generate spac TypeScript from it:
# Preview what will be generated (dry run)
npx @spec-spac/from-openapi petstore.json
# Generate to a directory
npx spac-from-openapi petstore.json --out ./generated
# Strip path prefixes for cleaner grouping
npx spac-from-openapi cloudflare.json --out ./generated \
--strip '/accounts/{account_id}' \
--strip '/zones/{zone_id}'| Flag | Description |
|---|---|
--out <dir> |
Output directory (omit for dry-run) |
--strip <prefix> |
Path prefix to strip before grouping (repeatable) |
--name <name> |
Override API title |
--spec-version <ver> |
Override OpenAPI version |
--debug |
Enable source map support in generated code |
Output structure:
generated/
index.ts — Api setup, imports all groups
shared/schemas.ts — Schemas used by 2+ groups
<group>/index.ts — Routes for that group
<group>/schemas.ts — Schemas local to that group
Every HTTP method returns a builder — all configuration is chained:
api.get('/users/:id')
.params(Type.Object({ id: Type.String() }))
.query(Type.Object({ fields: Type.Optional(Type.String()) }))
.response(User)
.error(404, ErrorSchema)
.summary('Get a user by ID')
.operationId('getUser')
.tag('users')Groups collect routes under a shared prefix with inherited metadata:
api.group('/users', g => {
g.tag('users')
g.security('bearer')
g.get('/').response(Type.Array(User))
g.post('/').body(CreateUser).response(User)
g.get('/:id').params(Type.Object({ id: Type.String() })).response(User)
})Wrap any TypeBox schema with named() to hoist it to components.schemas:
const Pet = named('Pet', Type.Object({ id: Type.String(), name: Type.String() }))
// Any route referencing Pet emits { "$ref": "#/components/schemas/Pet" }import { noContent, created, errorSchema } from '@spec-spac/spac'
api.post('/items')
.body(CreateItem)
.respond(201, created(Item))
.respond(204, noContent())
.error(400, errorSchema('Bad request'))Reusable transforms applied via .use():
import { macro } from '@spec-spac/spac'
const authenticated = macro.route(r => r.security('bearer'))
const audited = macro.group(g => { g.tag('audited'); g.security('bearer') })
api.group('/admin', g => {
g.use(audited)
g.get('/stats').response(Stats).use(authenticated)
})Map every line of emitted YAML back to the TypeScript that produced it:
const api = new Api('3.1', 'Petstore', { version: '1.0.0', debug: true })
// ... define routes ...
const result = api.emit({ sourceMap: true, generatedFile: 'spec.yaml' })
result.yaml // YAML string
result.sourceMap // standard Source Map V3
result.sourceTable // Map<jsonPath, SourceEntry>| Method | Description |
|---|---|
new Api(specVersion, title, config?) |
Create a new API spec (specVersion: '3.1') |
.get/.post/.put/.delete/.patch(path) |
Top-level route (returns builder for chaining) |
.group(prefix, callback) |
Group routes under a shared path prefix |
.server(config) |
Add a server |
.securityScheme(name, config) |
Register a security scheme |
.tag(config) |
Register a tag |
.security(name) |
Apply global security |
.use(macro) |
Apply an API-level macro |
.emit() |
Produce the OpenAPI 3.1 JSON document |
.emit({ yaml: true }) |
Emit as YAML string |
.emit({ sourceMap: true }) |
Emit YAML + Source Map V3 (requires debug: true) |
.params() .query() .headers() .body() .response() .respond() .error() .summary() .description() .tag() .operationId() .deprecated() .security() .server() .extension() .use()
- Node.js 22+
- pnpm 10+
git clone https://github.com/rbbydotdev/spac.git
cd spac
pnpm installpackages/
spac/ — Core library (@spec-spac/spac)
from-openapi/ — CLI + library for reverse-generating spac from OpenAPI
playground/ — Interactive playground (Vite + React + CodeMirror)
website/ — Documentation site (Next.js + Fumadocs)
theme/ — Shared Shiki syntax themes
spac-vscode/ — VS Code extension
examples/ — Example projects (petstore, plantstore, serpapi, etc.)
# Build the core library
pnpm --filter @spec-spac/spac build
# Run core tests
pnpm --filter @spec-spac/spac test
# Build + test the from-openapi package
pnpm --filter @spec-spac/from-openapi build
pnpm --filter @spec-spac/from-openapi testDocs site (Next.js):
pnpm --filter website dev
# → http://localhost:3000Playground (Vite):
# Generate fixtures first (required once, or after changing examples/spac source)
pnpm --filter spac-playground generate
# Start dev server
pnpm --filter spac-playground devThe generate step bundles type declarations, builds source maps, and creates example fixtures the playground needs. Re-run it after modifying example specs or the spac package.
# Install browser (first time only)
pnpm exec --filter spac-playground playwright install chromium
# Run tests
pnpm --filter spac-playground test:e2epnpm format # auto-fix with Biome
pnpm format:check # check only (used in CI)MIT

