Skip to content

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

Closed

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

  1. github-actions commented on Sep 13, 2026

    @github-actions
    Contributor

    🎯 Issue Triage

    The OpenAPI 3.1 emitter uses unevaluatedProperties for a leaf model's declared Record indexer, causing client generators without support for that keyword to interpret the dictionary as having no valid properties. For this shape, additionalProperties provides equivalent constraints with broader tooling compatibility.

    Category: Bug
    Area: emitter:openapi3

    🔗 [Playground Reproduction]((typespec.io/redacted)

    📋 Reproduction code
    model HealthReport {
      status: "ok";
      checks: Record;
    }
    🔍 Analysis

    This affects @typespec/openapi3 when targeting OpenAPI 3.1, specifically the indexer branch in packages/openapi3/src/schema-emitter-3-1.ts. The emitter currently selects unevaluatedProperties unconditionally for both sealing and declared-indexer cases. unevaluatedProperties remains necessary when sealing a schema or accounting for properties evaluated through a base model's allOf, but a leaf model with a declared indexer can use additionalProperties. Issues #3241 and #3549 are related to sealing/configuration but do not track this keyword-selection bug.

    📝 Suggested next steps
    • Add OpenAPI 3.1 emitter coverage for a leaf Record indexer, an inherited indexed model, and a sealed model.
    • Emit additionalProperties for the leaf declared-indexer case while retaining unevaluatedProperties where allOf or sealing semantics require it.

    Generated by Agentic Triage

  2. bimsonz commented on Sep 13, 2026

    @bimsonz
    ContributorAuthor

    Closing this — I filed it prematurely and it was not mine to file.

    On reflection I am not confident this is a defect in TypeSpec at all. unevaluatedProperties is valid JSON Schema and arguably the more precise keyword for a declared indexer; the complaint is really about client-generator support, which is a different argument. It may well be that the right home for any change is the downstream emitter, where it currently sits.

    It has been routed to the team that owns our TypeSpec work. If they judge it worth raising they will do so properly scoped. Apologies for the noise.

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