Skip to content

feat!: generate TypeScript from string templates and drop the TypeScript dependency - #2868

Open
im10furry wants to merge 1 commit into
openapi-ts:mainfrom
im10furry:feat/string-templates-drop-typescript-dep
Open

im10furry wants to merge 1 commit into
openapi-ts:mainfrom
im10furry:feat/string-templates-drop-typescript-dep

Conversation

@im10furry

Copy link
Copy Markdown

Changes

Closes #2841. Supersedes #2867. Related: #2862, #2818.

TypeScript 7 (the native compiler) is latest on npm and its root module ships no classic
compiler API
. openapi-typescript built its output with ts.factory / createPrinter /
createSourceFile, so a TS7 application hits two hard blockers:

  1. npm i typescript@7 openapi-typescript fails outright:
    npm error ERESOLVE unable to resolve dependency tree
    npm error peer typescript@"^5.x" from openapi-typescript@7.13.0
    
  2. Forcing past it (--legacy-peer-deps) crashes on first import:
    TypeError: Cannot read properties of undefined (reading 'createKeywordTypeNode')
    

This PR does what @mrlubos asked for in #2841"remove the entire TypeScript dependency and switch
to raw string templates"
— so TypeScript is no longer a dependency at all.

  • src/lib/ts.ts rewritten as a string builder. The helper surface keeps its shape (tsUnion,
    tsLiteral, oapiRef, tsEnum, …) but now returns source text, plus new line-oriented builders
    (typeLiteral, tupleType, propertySignature, indexSignature, typeAlias, interfaceDecl,
    enumDecl) and tsComment() for JSDoc.
  • All 13 transforms, lib/utils.ts and transform/index.ts migrated. Multi-line constructs thread
    an explicit indentation level, reproducing the printer's 4-space nesting exactly.
  • The printer's quirks are reproduced deliberately, each verified against the real printer:
    trailing whitespace stripped on every comment line; nested unions/intersections parenthesised
    (including inside array types and after readonly); tuples always multi-line while {} is inline;
    string literals escaped the way createStringLiteral does ("emoji🎉""emoji\uD83C\uDF89",
    while the tsLiteral UTF-8 workaround stays verbatim); and the $defs / template-literal /
    operations assembly keeps its exact shape.
  • openapiTS() resolves to a string — the complete file body, ending in a newline.
    astToString() is retained as a normalizer for older call sites.
  • peerDependencies.typescript removed; typescript stays a devDependency for the repo's own
    tsc. The built dist/ contains zero references to typescript.

Breaking changes (major)

Before After
openapiTS(): Promise<ts.Node[]> openapiTS(): Promise<string>
transformts.TypeNode | TransformObject transformstring | { schema: string; questionToken: boolean }
postTransform(type: ts.TypeNode) postTransform(type: string)
transformProperty(property: ts.PropertySignature) transformProperty(property: { name; optional; type; comment?; indent })
GlobalContext.injectFooter: ts.Node[] GlobalContext.injectFooter: FooterDeclaration[]
stringToAST(), tsModifiers(), QUESTION_TOKEN removed; see the new builders above

Migration for AST callbacks is mechanical: ts.factory.createTypeReferenceNode("Date")"Date".
Attach JSDoc from transformProperty with tsComment(tags, property.indent).

The inject option is now emitted verbatim rather than re-printed by the printer.

Two bugs fixed along the way

Both produced invalid TypeScript; both are covered by new tests.

  • A multi-line x-enum-descriptions entry leaked a bare token into the enum body
    (// line1\nline2 → now // line1 line2).
  • With pathParamsAsTypes, a URL containing a backtick broke out of the generated template literal
    type (`/a`b/${string}` → now `/a\`b/${string}`).

One deliberate, semantically-neutral output change: tsUnion()/tsIntersection() no longer emit a
redundant single-member union, so (string)[][] becomes string[][].

How to Review

  • The core is packages/openapi-typescript/src/lib/ts.ts. Everything else is a mechanical port
    against it; transform/schema-object.ts is the largest consumer.
  • Ignore the diff noise in the transform files — most of it is ts.factory.createX(...) becoming
    a string builder call plus an indent argument. The interesting parts are $defs, the array /
    arrayLength branches, and the operations assembly.
  • transform/index.ts keeps a typeLiteralMembers() helper that mirrors the old .members?.length
    check on the generated AST node, which is how the root paths/webhooks/components/$defs
    interface-vs-fallback decision still works.
  • The rewrite is one atomic commit on purpose: swapping the node type to string ripples through
    every transform, so the repo cannot be green in between. The byte-exact snapshot suite is what
    makes that safe.

Verification

Output parity. Every committed example snapshot diffs clean, including the 123,077-line GitHub
API outputs:

github-api                          diff=0      octokit-ghes-3.6-diff-to-api   diff=0
github-api-immutable                diff=0      stripe-api                     diff=0
github-api-export-type-immutable    diff=0      digital-ocean-api              diff=0
github-api-root-types               diff=0      github-api-next                diff=0
                                                enum-root-types                diff=0

Beyond the committed fixtures, I ran the published 7.13.0 and this branch side by side over a
purpose-built schema (discriminators, allOf/oneOf/anyOf, patternProperties, $defs, prefixItems,
readOnly/writeOnly, x-enum-* metadata, non-ASCII in descriptions, enum values, property names and
$ref targets) across 27 option combinations — including a kitchen-sink run — and every pair is
byte-identical. pnpm run update:examples produces no diff.

Suite. pnpm test → 26 files / 297 tests pass (byte-exact toMatchFileSnapshot fixtures, inline
want assertions, CLI snapshot tests, CJS bundle, Node API). tsc --noEmit,
tsc -p tsconfig.examples.json --noEmit and biome check are clean (the 3 remaining
noTemplateCurlyInString warnings are pre-existing on untouched lines).

Compiler matrix — same schema generated, then type-checked with each compiler:

TypeScript install generation parity tsc --noEmit --strict on output
5.9.3 clean identical PASS
6.0.3 clean identical PASS
7.0.2 (native) clean, no peer conflict identical PASS

End-to-end under TS7, the exact scenario from #2841:

$ npm i typescript@7 openapi-typescript-<this PR>.tgz    # no flags, no ERESOLVE
$ npx openapi-typescript ./schema.yaml -o ./out.d.ts      # ✅

With no TypeScript installed at all, both the CLI and the Node API (including transform,
postTransform and transformProperty) work — typescript does not even resolve from the consumer
project.

Checklist

  • Unit tests updated
  • docs/ updated (if necessary)
  • pnpm run update:examples run (only applicable for openapi-typescript)

Notes

Replace the TypeScript compiler API (ts.factory, createPrinter,
createSourceFile) with raw string templates, so the generator no longer
depends on TypeScript at runtime.

TypeScript 7 ships no classic compiler API, which currently breaks the
package twice over: the ^5.x peer range makes installation fail with
ERESOLVE, and forcing past it crashes with
"Cannot read properties of undefined (reading 'createKeywordTypeNode')".

Generation now emits source text directly, reproducing the printer's
formatting byte for byte: indentation, comment layout and trailing
whitespace, parenthesisation of nested unions/intersections, multi-line
tuples, and createStringLiteral's escaping of non-ASCII code units.
typescript is dropped from peerDependencies and is never resolved, so the
package works under TypeScript 5, 6 and 7 alike.

Also fixes two cases that produced invalid TypeScript: a multi-line
x-enum-descriptions entry leaked a bare token into the enum body, and
pathParamsAsTypes emitted an unterminated template literal for a URL
containing a backtick.

BREAKING CHANGE: openapiTS() resolves to a string instead of ts.Node[],
and the transform/postTransform/transformProperty hooks exchange strings
and plain objects instead of AST nodes.
@im10furry
im10furry requested a review from a team as a code owner September 11, 2026 07:26
@im10furry
im10furry requested a review from gzm0 September 11, 2026 07:26
@netlify

netlify Bot commented Sep 11, 2026

Copy link
Copy Markdown

👷 Deploy request for openapi-ts pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 26f4233

@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 26f4233

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openapi-typescript Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@im10furry

Copy link
Copy Markdown
Author

Open question: the transformProperty hook shape

I posted a fuller sign-off request on #2841; this is the one API decision I'd like a maintainer's call on before this lands, because it is the only place where the string rewrite can't preserve the old shape exactly.

What changed. transformProperty used to receive a ts.PropertySignature node and return one. A plain string can't carry that structure — the hook needs to read and change the property name, whether it is optional, and its type — so it now receives and returns a small plain object:

transformProperty?: (
  property: { name: string; optional: boolean; type: string; comment?: string; indent: string },
  schemaObject: SchemaObject,
  options: TransformNodeOptions,
) => typeof property | undefined;

comment and indent exist because the old hook could attach JSDoc via ts.addSyntheticLeadingComment, and a correctly-indented block comment needs to know the property's indentation. The PR adds a public tsComment(lines, indent) helper so hooks don't have to hand-build one:

transformProperty(property, schemaObject) {
  if (schemaObject.minLength === undefined) return undefined;
  return { ...property, comment: tsComment([`@minLength ${schemaObject.minLength}`], property.indent) };
}

The alternatives I considered:

  1. This PR — a structured { name, optional, type, comment?, indent } object. Keeps the old ergonomics (patch one field, return the object) and keeps indentation out of the caller's hands.
  2. Raw property string — the hook gets the already-rendered name?: type; line and returns a replacement line. Maximum flexibility and the most "string templates" spirit, but callers have to parse/re-emit the line and handle indentation themselves.
  3. Only change the return type — keep passing an object but let the hook return a plain string for the rendered property. A middle ground, but the asymmetry reads oddly.

I went with (1) as the closest analogue to the old API, but (2) is defensible if you'd rather keep the public surface strictly string-based. Happy to rework it — it's contained to transformProperty in src/transform/schema-object.ts, the hook's type in src/types.ts, and two test cases.

Everything else in the breaking-change list should be a mechanical migration for callers (ts.factory.createTypeReferenceNode("Date")"Date"), and CLI users plus consumers of the generated types need no migration at all.

@im10furry

Copy link
Copy Markdown
Author

CI note: test-e2e is stuck on an infrastructure step

Every check passes except test-e2e, which has been sitting in pnpm exec playwright install --with-deps for ~20 minutes. That step never completed, so the suite itself (pnpm run test-e2e) is still pending and hasn't run — this is not a test failure.

That job isn't exercised by this change: it runs the openapi-fetch Playwright suite, and packages/openapi-fetch/test/helpers.ts only imports openapi-typescript-helpers. The same code passes pnpm test on the macOS and Windows jobs, which do cover the packages.

It also matches the flakiness this job already has on main — it runs on node-version: latest, which is exactly what #2818 pins to Node 22 to avoid. For context, test-e2e is CANCELLED on #2862, #2867 and #2815.

I don't have permission to cancel or re-run the workflow (viewerPermission=none on this repo), so a maintainer re-run would be needed to clear it. Everything else is green, and the failure mode here is a stuck dependency download rather than anything in the diff.

@im10furry

Copy link
Copy Markdown
Author

Follow-up: the stuck test-e2e job here is a repo-wide, pre-existing failure, and I've opened #2869 to fix it.

test-e2e has been dying in pnpm exec playwright install --with-deps at GitHub's 6-hour job limit on every run since 2026-08-24 (last green run 32722419757, 79s). #2869 moves that job into the official Playwright container so there is nothing to install, and it is green there in 57s.

Once #2869 lands, rebasing this PR on main will make test-e2e green here too — nothing in this PR's diff is involved.

darkbasic added a commit to darkbasic/openapi-typescript that referenced this pull request Sep 12, 2026
Replace the generator's TypeScript compiler API with source-string builders
so applications can install TypeScript 7 without a peer dependency conflict
or a runtime failure at ts.factory. Generation also works without TypeScript
installed. Keep TypeScript as a workspace development dependency for builds
and semantic tests, rather than requiring a second compiler in consumers.

Adapt upstream PR openapi-ts#2868 (26f4233) onto this
fork's TypeScript 6 and required-only allOf improvements. This includes fixes
and regression coverage beyond the upstream string-template implementation.

Generator implementation

- Migrate the schema transforms, document assembly, and supporting utilities
  from AST nodes to TypeScript source fragments. Centralize declarations,
  properties, index signatures, tuples, arrays, enums, unions, intersections,
  literals, references, and comments in the existing ts helper module.
- Preserve established indentation and literal escaping where applicable.
  Keep expression fragments distinct from already-indented declaration and
  property lines through the builders' documented conventions.
- Retain deferred assembly of the operations interface so operations can be
  discovered incrementally without changing its position among footer types.
- Use context-sensitive parentheses for arrays and composed types, including
  readonly/keyof/typeof operands, functions, constructors, and conditionals.
  Skip quoted strings, nested templates, and comments when inspecting types.
  Recognize LF, CR, CRLF, U+2028, and U+2029 line-comment boundaries.
- Deduplicate generated helpers by their canonical declaration strings.
  An enum value or comment mentioning a helper name must not suppress the
  actual WithRequired, ReadonlyArray, or FlattenedDeepRequired declaration.
- Write fixed helper declarations as multiline templates rather than long
  strings containing escaped newlines, preserving their emitted bytes.

Preserve this fork's semantics

- Carry the full generated Readable/Writable implementation into the string
  emitter: the any fast path, recursive marker resolution, opaque callables,
  readonly collection methods and iterators, required numeric properties,
  extra data properties, and tuple length/position preservation.
- Keep callable arguments, results, and attached properties opaque while
  retaining visibility filtering on noncallable Date/RegExp data properties.
  Preserve the existing mutable-tuple projection and readonly never behavior.
- Keep the readonly-data mapping inline in generated helpers so a schema
  root named ReadonlyArrayData cannot collide with an implementation alias.
  The separate helpers package's existing implementation remains unchanged.
- Port required-only allOf handling without discarding known-key discovery,
  conservative safety gates, discriminator behavior, or callback occurrence
  tracking. Do not probe or replay user callbacks to infer their behavior.
- Implement named and collision-safe inline WithRequiredObject forms from
  the same string body, preserving object constraints, readonly properties,
  exact optional semantics, and rejection of nonobject/callable inputs.
- Inline the object-constraint helper when injected source, generated names,
  or arbitrary footer declarations make its fixed name unsafe. Keep existing
  conservative handling when property hooks can rename keys or deprecated
  fields are excluded; do not expand unrelated allOf behavior.

Correct generation regressions

- Render an array's element type once, then add its outer array dimension.
  Do not infer array/tuple structure from an emitted string's prefix/suffix.
  This preserves arrays of unions, tuple items, constant arrays, custom array
  types, and nested arrayLength elements independently of composition order.
- Emit readonly (readonly number[])[] for nested immutable arrays rather
  than invalid readonly readonly number[][]. Preserve parentheses around
  other low-precedence element types and rest-array operands as needed.
- Escape backticks, backslashes, and interpolation delimiters in generated
  path template literals. Match escaped parameter placeholders after escaping
  the URL so unusual parameter names still become typed interpolations.
- Flatten multiline enum descriptions into a single line comment, and omit
  redundant single-member union/intersection wrappers.
- Recognize nonempty braced root types independently of inline formatting
  and surrounding comments. Retain inner literal/template text, distinguish
  comment-only empty bodies, and reject outer unions/intersections/conditionals
  instead of turning them into invalid interface members.
- When postTransform is configured, emit nonempty object-shaped $defs roots
  as aliases, including when the hook leaves the root unchanged. This permits
  mapped root types without adding a TypeScript grammar classifier. Keep the
  existing fallback for empty, nonbraced, and composed roots.

Public API migration (major)

- openapiTS() returns Promise<string>, containing the generated body with a
  trailing newline, instead of Promise<ts.Node[]>.
- transform returns a type string or { schema: string, questionToken };
  postTransform receives and returns type strings.
- transformProperty receives and returns the structured object
  { name, optional, readonly, type, comment?, indent }. Initialize readonly
  from the schema/options and honor the returned value for both ordinary
  properties and $defs properties. Keep name, optionality, type, and JSDoc
  customization available without requiring callers to parse a property line.
- Provide tsComment() for indented JSDoc. GlobalContext.injectFooter now holds
  source declarations or deferred OperationsDeclaration instances.
- Replace AST-only helpers such as stringToAST(), tsModifiers(), and
  QUESTION_TOKEN with source builders. Existing AST callbacks must migrate;
  changing the application's compiler version alone is not that migration.
- Retain astToString() as a source-fragment joiner/trailing-newline helper.
  Reject AST inputs and the removed printer-options argument explicitly
  instead of accepting obsolete formatting options and silently ignoring them.
- Emit injected source without parsing and reprinting it through TypeScript.
  Document this behavior, the callback migration, and the $defs alias policy.

Packaging, documentation, and CI

- Remove the generator's TypeScript peer dependency. Published runtime code
  and declarations contain no TypeScript compiler imports.
- Correct CommonJS declarations to match the existing runtime namespace with
  default and named exports. Disable unbuild's declaration-only export-equals
  rewrite for this package through its supported declaration-options hook;
  keep the actual CommonJS runtime export shape unchanged.
- Publish @types/js-yaml and json-schema-to-ts because Redocly's public
  declarations reference them. Update the lockfile so strict consumers do
  not need undeclared dependencies or skipLibCheck to typecheck the API.
- Update English, Japanese, and Chinese Node API documentation, installation
  examples, contributor guidance, and the major changeset.
- Add an isolated packed-consumer test using the actual CLI, ESM and CommonJS
  APIs, all three hooks, public declarations, and generated type assertions.
  Reuse the existing helpers assertion suite against the packed helpers and
  both generated mutable/immutable helper implementations, including negative
  assertions for accidental widening or lost visibility/readonly constraints.
- Cover required-only allOf separately from property-hook customization,
  respecting the fork's existing safety gate. Include commented mapped-root
  output in the packed compiler checks as well as the focused unit tests.
- Keep network-dependent consumer installs separate from the normal unit
  suite. Run them outside workspace module resolution, with install scripts
  disabled and compiler-free resolution checked explicitly. Assert that
  packed artifacts exclude tests/scripts; exclude consumer fixture sources
  from the generator's ordinary tsconfig until they are generated in isolation.
- Add two consumer CI jobs, one each for Node 22 and 24. Each builds once and
  checks TypeScript 5.9.3, 6.0.3, 7.0.2, and no installed compiler in separate
  consumer projects. Retain all eight combinations without eight repeated
  workspace installs/builds, alongside the existing TS5/TS6 workspace matrix.

Validation and scope

- Final uncached workspace suite on Node 24 / TypeScript 6: 865 passing tests
  (377 generator, 451 fetch, 37 React Query), plus helper type assertions,
  example compilation, builds, and package export checks.
- Generator suite on TypeScript 5.9.3: 377 passing tests on both Node 22.23.2
  and Node 24.19.0. Generator, example, and helper typechecks pass on both.
- All eight final packed-consumer combinations pass on Node 22.23.2 and
  24.19.0, including TypeScript 7.0.2 strict declarations and compiler-free
  CLI/API generation. Package lint/typechecks, documentation build, and
  git diff --check pass; existing Biome configuration warnings remain.
- Framework typechecks for Vue, SvelteKit, and Next.js, plus the nonstrict
  fetch typecheck, passed during integration. Browser end-to-end tests were
  not rerun as part of this generator adaptation.
- Three independent review rounds covered correctness, maintainability,
  minimality, packaging, and repository conventions. Keep the established
  allOf safeguards and deferred operations representation; avoid a general
  type parser, footer registry, or unrelated historical arrayLength changes.
- Consumer compatibility does not imply running all workspace build tools
  under TS7: workspace development continues to use the classic TS5/TS6 API.

Upstream: openapi-ts#2868
Context: openapi-ts#2841
Fork TS6 work: 219be04

Co-authored-by: im10furry <im10furry@users.noreply.github.com>
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.

openapi-typescript doesn't work with Typescript 7

1 participant