Skip to content

[Bug]: OpenAPI 3.1 emitter writes a declared Record<T> indexer as unevaluatedProperties, which client generators do not implement #11953

Description

@bimsonz

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"]

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingemitter:openapi3Issues for @typespec/openapi3 emitter

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions