Skip to content

fix: unify TypeScript untagged-union conversion - #170

Merged
adamcavendish merged 1 commit into
mainfrom
adamcavendish/typescript-untagged-union-conversion
Nov 10, 2026
Merged

adamcavendish merged 1 commit into
mainfrom
adamcavendish/typescript-untagged-union-conversion

Conversation

@adamcavendish

@adamcavendish adamcavendish commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Convert camelCase TypeScript untagged unions through one value plan shared by
models, HTTP payloads, multipart inputs, exports, and tagged payloads/type guards.
Mixed client/search tools consistently use input_schema and max_uses on the
wire, including through aliases and nested containers.

Problem

Independent type and conversion walks disagreed about inline unions, aliases,
and container depth. One convertibility flag conflated renamed types with
runtime checks: identical integer | number representations could skip strict
ambiguity checks. Raw aliases could promise ergonomic types while preserving
wire keys instead.

Public/wire representations, required symbols, and runtime effects are separate
facts. Scalar enum constraints survive all supported OpenAPI lowerers; TypeScript
does not recover them by rereading raw specifications.

Behavior

typescript-fetch.untagged_union_strategy controls camelCase union conversion:

Policy Behavior
firstMatch (default) Concrete matches precede fallbacks; schema order is retained; unmatched input is preserved
strict Reject multiple original concrete matches or no match; fallbacks do not add ambiguity
raw Expose recursive wire types and preserve union subtrees unchanged, including inline aliases

Matching checks required own fields, scalar literals/enums, array and dictionary
values, and typed extensions separately from declared fields. Nested finite
constraints intersect at their original nullability boundaries; an outer enum
does not replace an inner enum or widen its values. Untyped scalar enums match
their finite values without inheriting a synthetic string constraint from the
legacy type projection. Selection is pure:
count/select first, then invoke one original delegate. Inner strict errors
propagate without retrying another member.

Repeated nullable wrappers do not create ambiguity or promote opaque fallbacks.
Authored concrete null matches beside unrestricted fallbacks remain visible to
outer selection. Null already accepted by an inner union still reaches that
union through properties and containers. An outer wrapper can admit null without
calling a non-nullable inner converter. Enum metadata restores an extracted
nullable conversion wrapper rather than passing null to that nonnullable
delegate; already-nullable inner unions still receive null and run strict checks.
Inner finite constraints stay inside that restored wrapper, preserving outer-only
null permission consistently between properties and array items.

Productive recursion and compound conversions use static internal functions,
not a runtime schema interpreter or guards weakened to unrestricted JSON. Only
matchers reachable from union checks are emitted. Helpers are not re-exported
by public barrels. Open dictionaries retain concrete conversion alongside opaque
entries; empty/entirely opaque dictionaries remain fallbacks.

Conversion helper modules retain their own model/child-helper dependencies, so
importing a usable model does not pull unrelated models into its consumer.

Raw subtrees stay wire-typed while ordinary parents and siblings still convert.
Singleton allOf aliases preserve arrays/null and readonly dictionaries. The
same projections handle nested JSON requests, successful and lazy error
responses, JSON multipart parts, and direct tagged payloads/guards. A required
schema-nullable JSON body accepts supplied null but rejects omission. Optional
JSON bodies skip conversion only for undefined; supplied null retains the same
strict checks as direct or required conversion. Multipart preserves supplied
upload filenames, binary bytes and omitted optional parts.

Compatibility and boundaries

Generated converter names and readonly contracts are retained, including the
existing readonly-outer/plain-inner nested array contract. Preserve mode adds no
JSON converters. Helper inputs accept readonly arrays without widening public
API results. Imports, declarations, guards and expressions are laid out directly
for full Vite+ checks before and after packing; no formatter preparation or
generated-source compatibility patch is needed. CI bootstrap follows Vite+ 1.5.1
and TypeScript 7.0.2.

Published sigil-stitch 0.7.5 supplies semantic qualified utility types: models
named Record or Readonly keep their public names, while built-in references
use globalThis.Record or globalThis.Readonly. Open external declarations and
guards share their wrapper type, retaining nested imports without raw types.

The other eight generators consume retained legacy representations rather than
adopting a new enum or serialization policy. Rust library consumers exhaustively
matching IrTypeExpr must handle ScalarEnum, including its
representation_is_constraint fact distinguishing authored constraints from
legacy fallbacks and representation_nullable recording where property
nullability was extracted. Exhaustive TypeScriptFetchConfig literals initialize
untagged_union_strategy or use ..Default::default().

Matching is structural conversion dispatch, not full JSON Schema validation of
formats, bounds or every keyword. Multi-member allOf composition is not
redesigned. Same-value circular aliases remain outside this change and can still
cause TS2456; exploratory consumers do not claim full-barrel compilation when
their fixtures contain those unsupported aliases. No subprocess-driven test
harness, Python tooling/union repair, warning suppression, release change or
publication is introduced.

@adamcavendish
adamcavendish force-pushed the adamcavendish/typescript-untagged-union-conversion branch 3 times, most recently from 9218fbc to d0f9b04 Compare November 10, 2026 15:32
@adamcavendish adamcavendish changed the title fix: convert TypeScript untagged union members fix: unify TypeScript untagged-union conversion Nov 10, 2026
@adamcavendish
adamcavendish force-pushed the adamcavendish/typescript-untagged-union-conversion branch from d0f9b04 to 4e31d73 Compare November 10, 2026 18:34
- Plan public and wire types, export symbols, and runtime effects once for
  models, HTTP payloads, multipart inputs, and tagged payloads and guards.
- Retain nested scalar enum constraints at their original nullable boundaries
  without treating synthetic type fallbacks as constraints or changing other
  generators' existing representations and codec policies.
- Count original concrete candidates before fallbacks, delegate once, and retain
  authored null matches and nested strict errors through properties and containers.
- Keep raw union subtrees wire-typed without disabling ordinary parent conversion
  or weakening readonly dictionary and nested array contracts.
- Emit static internal conversion and reachable matching helpers with structured
  Vite+-stable layout, retaining full checks on untouched output before and after
  packing, isolated model dependencies, and no public barrel helper exports.
- Use published sigil-stitch 0.7.5 for qualified utility types with tracked nested
  imports, preserving models named Record or Readonly and their converter names.
- Distinguish omitted JSON bodies from supplied null in required and optional
  requests, restoring nullable wrappers without erasing inner finite constraints;
  retain native consumer, declaration, exact render and golden contracts.
@adamcavendish
adamcavendish force-pushed the adamcavendish/typescript-untagged-union-conversion branch from 4e31d73 to 9db7a0a Compare November 10, 2026 21:09
@adamcavendish
adamcavendish merged commit 053fa5a into main Nov 10, 2026
31 checks passed
@adamcavendish
adamcavendish deleted the adamcavendish/typescript-untagged-union-conversion branch November 10, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant