How-to › Describe your API

How to reuse schemas and responses with components and $ref#

Move every repeated schema, parameter, and response under components and reference it with $ref, so generators emit one named type instead of a copy per use.

Audience
API producer
Level
beginner
Topic
Write an OpenAPI description
Verified

Your generated client has three types for one meter, one per operation that returns it, under three names the generator invented. A field added to the meter is three edits in the document and three in every client, and the review cannot see that they describe one thing. The reference docs render the same object three times.

What you get

You will end up with one named schema, parameter, response, request body, and header for each repeated piece, referenced by $ref, and generated types that carry those names. This is for you if your generated clients show one object several times under different names.

Short answer

Put every schema, parameter, header, response, and request body that appears twice under components, give it a name you would give a class, and point each use at it with $ref. Generators turn component keys into type names, so an inline schema becomes an anonymous type per use. In OpenAPI 3.1 a $ref may carry a summary and description, and a Schema Object $ref may sit beside other keywords; in 3.0 anything beside $ref is ignored.

You will need

An OpenAPI 3.1 document with at least one repeated schema, and Node 22 or later for the generator and the linter that show the difference. Verified 2026-10-17 against Node 22.22.0, openapi-typescript 7.13.0, @stoplight/spectral-cli 6.16.3, TypeScript 5.9.3, and yaml 2.8.2, installed from npm in the sample directory. The rules come from the Components Object and Reference Object sections of OpenAPI 3.1.0, and from the 3.0.3 text where the two differ.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Components across files with relative $refSeveral teams own different resources and review by fileA bundling step for tools that read one file, and a generator that may name the type after the file rather than the componentOne team edits one file and merge conflicts are rare
Components in one documentMost documents: one file every tool reads, with every repeated piece named onceNames you must choose well, because every generator turns the key into a type name, and a components section that grows with the APIA description too large for one file to review
Inline schemasA throwaway document, or a shape used exactly onceAn anonymous type per use in every generated client, and the same object rendered once per operation in the docsAnything a client will be generated from more than once

Inline is quick to write and expensive to generate from: every use is a type of its own, named by the generator after the operation or left anonymous. One document with a components section costs you naming, since the key becomes the type name in every language you generate. Splitting across files buys review and ownership at the price of a bundling step and, in the generator used here, a type named after the file.

Name the schema once and reference it

The document below describes the same three operations as an inline copy of it, with every repeated piece moved under components and referenced from where it is used.

  /meters/{id}:
    get:
      operationId: get_meter
      description: One meter by id.
      tags: [meters]
      parameters:
        - $ref: '#/components/parameters/MeterId'
      responses:
        '200':
          description: One meter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Meter'
                description: The meter that was asked for. A sibling of $ref, which 3.1 allows.
        '404':
          $ref: '#/components/responses/NotFound'
components:
  schemas:
    Meter:
      type: object
      description: A metering point.
      required: [id, serial]
      properties:
        id: { type: string }
        serial: { type: string }
        installed_at: { type: string, format: date-time }
        owner:
          $ref: '#/components/schemas/Party'
          description: Who pays for this meter. A sibling of $ref, which 3.1 allows.
    Party:
      type: object
      description: A customer or a landlord.
      properties:
        name: { type: string }

types.mjs runs openapi-typescript over the inline copy, this document, and a third copy whose schemas live in their own files. It counts two things: the names under components.schemas in the output, and how often the meter is spelled out as an object literal inside the operations.

node types.mjs
inline.yaml
  named schema types                none
  meter literals in the operations  3
components.yaml
  named schema types                Meter, Party, MeterInput, Problem
  meter literals in the operations  0
split/openapi.yaml
  named schema types                Meter, Problem, meter, problem
  meter literals in the operations  0
components.yaml, a description beside a $ref
  on a property      kept, as a JSDoc comment
  on a media type    dropped

The inline document produces no named types and three copies of the meter, one per operation. The components document produces four names and no copies; every use is components["schemas"]["Meter"]. OpenAPI Generator makes the same distinction with names rather than literals. Its documentation says inline schemas become separate schemas whose auto-generated name “may not look good to everyone,” and gives meta_200_response as its own example.1 The component key is the name you would have had to pick anyway, so pick it as you would name a class.

The split tree is read without a bundle, and the generator hoists each file under its filename: meter, with Meter kept as an alias and both operations typed against meter. The name you chose survives as that alias, but the one the client shows is the file’s.

Reuse parameters, responses, headers, and request bodies the same way

The Components Object has ten fixed fields in 3.1: schemas, responses, parameters, examples, requestBodies, headers, securitySchemes, links, callbacks, and pathItems. Each is a map from a name to the object or to a $ref, and every key must match ^[a-zA-Z0-9\.\-_]+$.2

  parameters:
    MeterId:
      name: id
      in: path
      required: true
      description: The meter id.
      schema: { type: string }
  headers:
    RateLimitRemaining:
      description: Requests left in the current window.
      schema: { type: integer }
  requestBodies:
    MeterInput:
      required: true
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/MeterInput'
  responses:
    NotFound:
      description: No meter has that id.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    Unprocessable:
      description: The body did not validate.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
node print-types.mjs components.yaml components
    responses: {
        /** @description No meter has that id. */
        NotFound: {
            headers: {
                [name: string]: unknown;
            };
            content: {
                "application/problem+json": components["schemas"]["Problem"];
            };
        };
        /** @description The body did not validate. */
        Unprocessable: {
            headers: {
                [name: string]: unknown;
            };
            content: {
                "application/problem+json": components["schemas"]["Problem"];
            };
        };
    };
    parameters: {
        /** @description The meter id. */
        MeterId: string;
    };
    requestBodies: {
        MeterInput: {
            content: {
                "application/json": components["schemas"]["MeterInput"];
            };
        };
    };
    headers: {
        /** @description Requests left in the current window. */
        RateLimitRemaining: number;
    };

Every key is a name in the output, and both error responses point at one Problem. A client that maps application/problem+json to a form, or an SDK that throws a typed error, is written once against Problem rather than once per operation.

Put a description beside the $ref only in 3.1

The two versions disagree about what may sit next to $ref. In 3.0.3 the Reference Object has one fixed field, $ref, and the specification says that any properties added to it SHALL be ignored, in capitals. In 3.1.0 the Reference Object gains summary and description, which by default override the referenced component’s. The Schema Object is JSON Schema 2020-12, whose $ref is an applicator that other keywords can appear alongside.3 The same document under Spectral, once as 3.0.3 and once as 3.1.0:

npx spectral lint sibling-3.0.yaml
 31:30  error  no-$ref-siblings  $ref must not be placed next to any other properties  paths./meters/{id}.get.responses[200].content.application/json.schema.description
 41:24  error  no-$ref-siblings  $ref must not be placed next to any other properties  components.schemas.Meter.properties.owner.description

✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints)
npx spectral lint sibling-3.1.yaml
No results with a severity of 'error' found!

The Spectral rule no-$ref-siblings exists because 3.0 tooling drops the sibling, and the rule turns itself off for 3.1. The generator run earlier shows that the permission is not the same as support. In the 3.1 document it kept the description on the owner property as a doc comment and dropped the one on the media type schema.4 Write the sibling where your tools read it, and keep the referenced component’s own description as the one that is always rendered.

Check it worked

The test that guards the design generates types from both documents and asserts the names and the counts the comparison promised.

test('components become named types; inline schemas become one literal per use', async () => {
  const withComponents = await generate('components.yaml')
  assert.deepEqual(schemaNames(withComponents), ['Meter', 'Party', 'MeterInput', 'Problem'])
  assert.equal(meterLiteralsInOperations(withComponents), 0)
  const inline = await generate('inline.yaml')
  assert.deepEqual(schemaNames(inline), [])
  assert.equal(meterLiteralsInOperations(inline), 3)
})
node --test refs.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6281.578862

Six tests pass. The others walk every $ref in the document and assert that each resolves, and check every component key against the pattern. Two more pin the split tree’s file-named types and run Spectral over the 3.0 and 3.1 documents and the two documents in the next section.

When it goes wrong

A path item kept under components.pathItems and referenced from paths is valid 3.1: the Path Item Object allows a $ref to a referenced definition. The generator reads it, but the linter rejects it.

paths:
  /meters/{id}:
    $ref: '#/components/pathItems/MeterById'
node print-types.mjs pathitems.yaml paths
export interface paths {
    "/meters/{id}": components["pathItems"]["MeterById"];
}
npx spectral lint pathitems.yaml
 14:16  error  oas3-schema  "~1meters~1{id}" property must not have unevaluated properties.  paths./meters/{id}

✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)

The Spectral rule oas3-schema reads the referenced path item as an unexpected property. Until every tool in your pipeline agrees, write path items in place under paths and reuse their parts, which is what components.yaml does.

A component key with a space fails validation, and the message names the pattern.

npx spectral lint badkey.yaml
 29:11  error  oas3-schema  "schemas" property must match pattern "^[a-zA-Z0-9._-]+$".  components.schemas
 29:11  error  oas3-schema  "schemas" property property name must be valid.             components.schemas

✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints)

Meter Reading cannot be a key, and neither can a name with a slash or a colon. Use MeterReading, which is also what the generated type will be called.

A generated type is named after a file. The schemas were split into files and the generator was pointed at the tree, as the split run showed. Bundle by reference first, which split and bundle a large OpenAPI document covers, so the generator sees one document with your component names.

When not to do this

Do not move a schema used once into components to be tidy. A component is a name you have promised to every generated client, and removing or renaming one is a breaking change to clients that never asked for it.

Do not name a component after the operation that returns it. GetMeterResponse is what a generator invents when you gave it nothing, and a document that invents the same thing by hand has spent the one advantage of naming.

Do not put a description beside a $ref in a document that declares openapi: 3.0.x. The specification says it is ignored, Spectral says so, and most tools drop the text you wrote.

Do not reference path items from paths while a linter that rejects them guards your pipeline. The feature is in the specification, but the tool run here refuses it, so the green build is worth more than the reuse.

Do not split a document across files before a second team edits it. The split costs a bundling step, and this generator a type name, for conflict-free editing a single editor never needed.

Last verified

Verified 2026-10-17 against Node 22.22.0, openapi-typescript 7.13.0, @stoplight/spectral-cli 6.16.3, TypeScript 5.9.3, and yaml 2.8.2. Every output block is what the command preceding it printed. OpenAPI Generator was not run; its naming is quoted from its documentation. Redoc and Scalar were not run, so the rendering claims are what their documentation and the generated types imply rather than screenshots.

Footnotes

  1. The OpenAPI Generator page on inline schema naming says that inline schemas become separate schemas automatically and that the auto-generated name “may not look good to everyone,” in its own words. It then shows meta_200_response and inline_object_2 being renamed with --inline-schema-name-mappings. A tool that documents its own names as a problem and ships a flag for them has described the problem precisely. ↩︎ Back to text

  2. The key pattern ^[a-zA-Z0-9\.\-_]+$ appears word for word in 3.1.0 and in 3.0.3. It escapes the dot, which needs no escaping inside a character class, and the hyphen, which does where it stands. Without its backslash, that hyphen would make a range from the dot to the underscore, taking in the slash and the colon. The pattern admits a name made entirely of dots. It shuts out the space, the slash, and the tilde, so a key goes into a $ref fragment as written, with no JSON Pointer escape and no percent-encoding. ↩︎ Back to text

  3. JSON Schema’s core specification states the permission as an aside. Section 8.2.3.1 defines $ref as an applicator whose results are the results of the referenced schema, and then adds, in a note, that this definition means other keywords can appear alongside it. The rule that changed a decade of tooling behavior is a consequence noted in passing. ↩︎ Back to text

  4. The Spectral rule documentation gives the history in two sentences. Before 3.1, keywords next to $ref “were ignored by most tooling, but not all,” and the result was “inconsistent experiences depending on what combinations of tools are used,” in its words. The generator on this page keeps one sibling and drops another in a 3.1 document, so the inconsistency has survived the rule that was written about it. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.