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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
Components across files with relative $ref | Several teams own different resources and review by file | A bundling step for tools that read one file, and a generator that may name the type after the file rather than the component | One team edits one file and merge conflicts are rare |
| Components in one document | Most documents: one file every tool reads, with every repeated piece named once | Names you must choose well, because every generator turns the key into a type name, and a components section that grows with the API | A description too large for one file to review |
| Inline schemas | A throwaway document, or a shape used exactly once | An anonymous type per use in every generated client, and the same object rendered once per operation in the docs | Anything 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.
Related how-tos
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
-
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_responseandinline_object_2being 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 -
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$reffragment as written, with no JSON Pointer escape and no percent-encoding. ↩︎ Back to text -
JSON Schema’s core specification states the permission as an aside. Section 8.2.3.1 defines
$refas 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 -
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