Summary
For a model whose only extra constraint is a declared indexer — i.e. a plain dictionary such as
Record<string> — the OpenAPI 3.1 emitter writes the indexer as unevaluatedProperties. That is
defensible as JSON Schema, but in practice essentially no TypeScript client generator implements
unevaluatedProperties, so the field is read as having no permitted properties: a dictionary
becomes Record<string, never>, an object that must be empty.
additionalProperties expresses the same constraint for that shape and is universally supported.
Repro
model HealthReport {
status: "ok";
checks: Record<string>;
}
Emitted (openapi-versions: ["3.1.0"]):
"checks": {
"type": "object",
"unevaluatedProperties": { "type": "string" }
}
Expected:
"checks": {
"type": "object",
"additionalProperties": { "type": "string" }
}
Downstream, a generated TypeScript client types checks as an object with no permitted properties,
so writing report.checks["db"] = "ok" is a type error and reading any key yields never. The
service returns a perfectly good document; only the generated client is wrong.
Where
packages/openapi3/src/schema-emitter-3-1.ts, the indexer branch of the 3.1 schema emitter
(dist/src/schema-emitter-3-1.js around the shouldSealSchema check in 1.15.0 and 1.16.0, both
identical here):
const unevaluatedPropertiesSchema = shouldSeal
? { not: {} }
: this.emitter.emitTypeReference(model.indexer.value);
setProperty(schema, "unevaluatedProperties", unevaluatedPropertiesSchema);
The keyword is chosen unconditionally, so the sealing case and the declared-indexer case get the
same one.
Suggested fix
Choose the keyword by case rather than emitting one for both. unevaluatedProperties is genuinely
required when the model is sealed (not: {}) and when it has a base model, because there
the properties to account for are evaluated by an allOf subschema that additionalProperties
cannot see. A leaf dictionary has neither, and additionalProperties is correct and portable:
const indexerKeyword =
shouldSeal || model.baseModel ? "unevaluatedProperties" : "additionalProperties";
setProperty(schema, indexerKeyword, indexerSchema);
This is not hypothetical — it is the patch we and others apply to ship. It has been carried across
1.15.0 and 1.16.0 unchanged, and is republished per compiler release, which is why it would be
better upstream than in every consumer.
Why not an existing option
seal-object-schemas governs sealing (defaulting closed models to additionalProperties: false /
unevaluatedProperties: false). It does not affect which keyword a declared indexer emits, and
turning it on would be wrong here anyway — the model is a dictionary, not a sealed object. As far as
I can find there is no emitter option covering this; #3241 and #3549 are related but both address
sealing/configuration rather than the indexer keyword.
Environment
|
|
@typespec/openapi3 |
1.15.0 and 1.16.0 (identical in this region) |
@typespec/compiler |
1.16.0 |
openapi-versions |
["3.1.0"] |
Summary
For a model whose only extra constraint is a declared indexer — i.e. a plain dictionary such as
Record<string>— the OpenAPI 3.1 emitter writes the indexer asunevaluatedProperties. That isdefensible as JSON Schema, but in practice essentially no TypeScript client generator implements
unevaluatedProperties, so the field is read as having no permitted properties: a dictionarybecomes
Record<string, never>, an object that must be empty.additionalPropertiesexpresses the same constraint for that shape and is universally supported.Repro
Emitted (
openapi-versions: ["3.1.0"]):Expected:
Downstream, a generated TypeScript client types
checksas an object with no permitted properties,so writing
report.checks["db"] = "ok"is a type error and reading any key yieldsnever. Theservice returns a perfectly good document; only the generated client is wrong.
Where
packages/openapi3/src/schema-emitter-3-1.ts, the indexer branch of the 3.1 schema emitter(
dist/src/schema-emitter-3-1.jsaround theshouldSealSchemacheck in 1.15.0 and 1.16.0, bothidentical here):
The keyword is chosen unconditionally, so the sealing case and the declared-indexer case get the
same one.
Suggested fix
Choose the keyword by case rather than emitting one for both.
unevaluatedPropertiesis genuinelyrequired when the model is sealed (
not: {}) and when it has a base model, because therethe properties to account for are evaluated by an
allOfsubschema thatadditionalPropertiescannot see. A leaf dictionary has neither, and
additionalPropertiesis correct and portable:This is not hypothetical — it is the patch we and others apply to ship. It has been carried across
1.15.0 and 1.16.0 unchanged, and is republished per compiler release, which is why it would be
better upstream than in every consumer.
Why not an existing option
seal-object-schemasgoverns sealing (defaulting closed models toadditionalProperties: false/unevaluatedProperties: false). It does not affect which keyword a declared indexer emits, andturning it on would be wrong here anyway — the model is a dictionary, not a sealed object. As far as
I can find there is no emitter option covering this; #3241 and #3549 are related but both address
sealing/configuration rather than the indexer keyword.
Environment
@typespec/openapi3@typespec/compileropenapi-versions["3.1.0"]