Skip to content

feat: add $dynamicRef / $dynamicAnchor schema resolution for OpenAPI 3.1#3889

Open
aqeelat wants to merge 8109 commits into
hey-api:mainfrom
aqeelat:feat/dynamicref-support
Open

feat: add $dynamicRef / $dynamicAnchor schema resolution for OpenAPI 3.1#3889
aqeelat wants to merge 8109 commits into
hey-api:mainfrom
aqeelat:feat/dynamicref-support

Conversation

@aqeelat

@aqeelat aqeelat commented May 15, 2026

Copy link
Copy Markdown
Contributor

Closes #3886

What changed

  • spec-types: Add $dynamicRef, $dynamicAnchor, $defs to JSON Schema 2020-12 type definitions
  • IR types: Add typeParams and typeArgs fields to IRSchemaObject
  • SchemaState: Add dynamicScope and typeParams fields for dynamic anchor → type ref propagation and template parameter tracking through the parser
  • dynamicRef.ts module (new file):
    • buildDynamicScope() — builds dynamic scope from own $dynamicAnchor and $defs bindings
    • buildCurrentDynamicScope() — merges inherited + current scope; current scope wins on shadowing
    • getTemplateParams() — detects generic template schemas by finding $defs entries with $dynamicAnchor but no $ref
    • buildGenericRef() — constructs IR nodes with $ref + typeArgs for generic type references; preserves null from nullable schemas
    • anchorToParamName() — converts anchor names to valid TypeScript identifiers via toCase(anchor, 'PascalCase') + ID_Start/ID_Continue sanitization
    • materializeDynamicRefBinding() — materializes inline $dynamicRef bindings
    • resolveDynamicRef() — resolves a $dynamicRef string against the current dynamic scope
    • shouldInlineDynamicRefTarget() — determines when a $ref target should be inlined
  • schema.ts: New $dynamicRef dispatch branch in schemaToIrSchema() — resolves through dynamic scope (recursive/concrete cases) or as a type parameter reference inside generic templates; parseRef() updated to inline dynamic ref targets when scope dictates
  • TypeScript visitor: Updated intercept to handle #typeParam/ refs (type parameters) and $ref + typeArgs (generic references)
  • TypeScript export: Updated exportAst to emit .generic() on type aliases with type parameters
  • Tests: 815-line unit test suite for all dynamicRef.ts helpers + 3 integration test fixtures with snapshots (petstore showcase, external-ref, scope-isolation)

Supported patterns

Pattern Result
Self-referential $dynamicAnchor (e.g. BaseCategory.children) Recursive concrete type via materialization (was Array<unknown>)
Generic template via $defs (e.g. PaginatedTemplate<ItemType>) Generic type alias with type parameters
Bound generic usage (e.g. PaginatedUserResponse) Generic reference PaginatedTemplate<User>
Nullable bound generic (e.g. type: ['object', 'null'] + $defs) Union with null preserved: PaginatedTemplate<User> | null
Recursive generic reference (e.g. ShelterFolder = ShelterFolderTemplate<ShelterFolder, ShelterResource>) Circular generic reference preserved correctly
Inline route-response $dynamicRef bindings Resolved with concrete types (was unknown)
Shadowed dynamic anchors (inner scope overrides outer) Innermost binding wins per JSON Schema 2020-12 dynamic scope rules
Non-identifier anchor names (e.g. item-type) Sanitized to valid TypeScript identifiers (ItemType)

Unsupported (falls back to unknown)

Pattern Reason
External $dynamicRef (e.g. other.json#node) The schema bundler only resolves $ref URIs — external files referenced by $dynamicRef are never fetched. #3902
Ambiguous $dynamicAnchor (multiple same-named anchors) Static analysis cannot determine which schema

Limitations

  • Shared component schemas with endpoint-specific bindings: Each schema in components.schemas is generated once with a single scope. If the same named component is referenced by multiple endpoints that each provide different $defs bindings, only one binding applies. The common pattern — putting $defs bindings on inline response schemas — works correctly.

Design

  • Two-path resolution — recursive/concrete $dynamicRef patterns are resolved via dynamic scope materialization (no plugin changes needed). Generic/template $dynamicRef patterns are preserved as TypeScript generics through IR typeParams/typeArgs fields with TypeScript visitor support.
  • No config flag — purely additive. Only triggers when $dynamicRef is present in the spec. Current behavior for such specs is unknown; any improvement is strictly better.
  • Plain-name anchors only$dynamicRef values containing / (JSON pointer fragments) are not resolved.
  • type aliases for generics — Generic templates use type aliases (not interface) since ts-dsl already supports generics on aliases and no declaration merging is needed on generated types.
  • Type parameter names — Derived from anchor names via toCase(anchor, 'PascalCase') and then sanitized against JavaScript ID_Start/ID_Continue rules to ensure valid TypeScript identifiers (e.g., item-typeItemType, folderTypeFolderType).
  • Dynamic scope shadowing — Current/inner scope bindings override inherited/outer scope bindings, matching JSON Schema 2020-12 dynamic scope resolution semantics.

SukkaW and others added 30 commits April 22, 2026 01:15
… intersections

When discriminated union members have allOf schemas, they generate ZodIntersection
which does not support .extend(). Detect this case by storing isIntersection: true
in the symbol meta and fall back to z.union() in tryBuildDiscriminatedUnion.

- Add isIntersection field to ZodMeta type
- Set isIntersection: true in intersection() handler of all three walkers
- Set isIntersection: false in union() handler (manual meta) of all three walkers
- Merge ctx.meta with isIntersection flag in processor exportAst calls
- Check querySymbol meta for isIntersection in tryBuildDiscriminatedUnion
- Add test specs and snapshots for the discriminator-allof-member scenario

Agent-Logs-Url: https://github.com/hey-api/openapi-ts/sessions/1c6f992d-8feb-49c9-9cc6-7830259cfe5c
response/request object may become undefined after the changes, but in
our test case we can always assert they do exists.
mrlubos and others added 21 commits May 18, 2026 18:05
fix(params): guard __proto__, constructor, and prototype sub-keys
When two anchors PascalCase to the same identifier (e.g. item_type and
item-type both produce ItemType), getTemplateParams now appends a numeric
suffix to the colliding name instead of emitting duplicates.
…ef bindings

Extract containsRefTo helper that walks allOf, anyOf, and oneOf to detect
circular references instead of only checking direct $ref and single-level
allOf. Add integration fixture with cycle through oneOf and unit tests.
…2.3.2

Reverse spread order in buildCurrentDynamicScope so inherited (outer)
scope overrides current (inner) scope on collision, matching the spec's
requirement that the outermost matching $dynamicAnchor takes precedence.
…fBinding

When materializing a dynamic ref binding, the caller's $defs now merge
with the template's $defs instead of wholesale replacing them. This
preserves helper sub-schemas that the template may reference from its
body alongside the anchor placeholders.
@aqeelat aqeelat force-pushed the feat/dynamicref-support branch from 78a2fef to 9a871a9 Compare May 19, 2026 09:33
aqeelat added 4 commits May 25, 2026 23:14
- Switch anchorToParamName from PascalCase to sanitized raw anchor casing
  (e.g. itemType instead of ItemType) to match orval-labs/orval#3353
- Remove circular-binding materialization branch in schema.ts; circular
  refs now emit generic alias form (type X = Generic<X>) matching Orval
- Exclude circular snapshot dirs from tsconfig to suppress TS2456 errors
  (follow-up: emit interface extends via InterfaceTsDsl)
- Update unit tests and integration snapshots
…s to fix TS2456

Add InterfaceTsDsl to ts-dsl and circularTypeAlias flag to IR. When a
circular type alias would produce TS2456 (type X = Generic<X> & {...}),
emit an interface declaration with extends clause instead. Handles
circular refs through allOf; union and naked circular refs remain as
type aliases (known limitation, same as Orval).
… types

Handle TypeExprTsDsl (bare generic reference without intersection) in
exportAst so that patterns like type X = Generic<X> emit as
interface X extends Generic<X> {} instead, avoiding TS2456.
Document that union circular refs (e.g. type X = A | Generic<X>) fall
through to type alias because interfaces cannot represent unions. Add
comment in export.ts, update spec fixture description, and update test
description.
…icRef

When a $dynamicRef anchor isn't found in the dynamic scope, scan
components.schemas for a schema declaring the same $dynamicAnchor.
Per JSON Schema 2020-12, $dynamicRef falls back to $ref behavior
when no dynamic scope override exists.

Also reorders resolution: typeParams (template placeholders) now take
precedence over the components fallback, preventing unbound generic
params from being hijacked by unrelated schemas with the same anchor.

Ports orval hey-api#3446 and hey-api#3447.
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Jun 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs 📃 Documentation updates. feature 🚀 Feature request. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: support $dynamicRef / $dynamicAnchor for OpenAPI 3.1 schemas

8 participants