diff --git a/.changeset/quiet-compilers-coexist.md b/.changeset/quiet-compilers-coexist.md new file mode 100644 index 000000000..65221e666 --- /dev/null +++ b/.changeset/quiet-compilers-coexist.md @@ -0,0 +1,9 @@ +--- +"openapi-typescript": major +--- + +Support applications using TypeScript 7 by making the JavaScript compiler a runtime dependency instead of a peer dependency. The application compiler and generator compiler can now be installed independently, without aliases or custom loaders. + +The generator's compiler is exported as `ts`. **Breaking:** Node API consumers that create, inspect, or print AST nodes must import `ts` from `openapi-typescript` instead of a separately installed `typescript`. This keeps factories, type guards, AST types, and the printer on the same compiler version. CLI usage and generated types are unchanged. + +CommonJS declarations now match the existing runtime: `require("openapi-typescript")` returns named exports (including `ts`) and a `.default` generator function, not a directly callable function. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014c1f885..4b2aab3eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: - uses: pnpm/action-setup@v5 with: run_install: true + - run: pnpm --filter openapi-typescript test:typescript-7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - run: pnpm --filter openapi-typescript test:typescript-7 --bun + - run: pnpm --filter openapi-typescript test:typescript-7 --bun --linker isolated - run: pnpm test test-e2e: runs-on: ubuntu-latest diff --git a/docs/introduction.md b/docs/introduction.md index cff0f2ac2..19e65c8f9 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -36,6 +36,8 @@ This library requires the latest version of [Node.js](https://nodejs.org) instal npm i -D openapi-typescript typescript ``` +Your application can use TypeScript 7. The generator installs its own JavaScript TypeScript compiler dependency, independently of your application's `tsc`. No package aliases or custom loaders are needed. Generated types remain runtime-free. + And in your `tsconfig.json`, to load the types properly: ::: code-group diff --git a/docs/node.md b/docs/node.md index bd837887a..a48212b5b 100644 --- a/docs/node.md +++ b/docs/node.md @@ -10,9 +10,15 @@ The Node API may be useful if dealing with dynamically-created schemas, or you ## Setup ```bash -npm i --save-dev openapi-typescript typescript +npm i --save-dev openapi-typescript ``` +The generator installs its own JavaScript TypeScript compiler. Your application can use TypeScript 7 independently. For AST factories, type guards, printers, and AST types, import `ts` from `openapi-typescript` so your code uses the same compiler as the generator. + +The JavaScript compiler remains an install-time dependency (about 23.6 MB unpacked for TypeScript 5.9.3). Package managers can share it with a compatible application compiler; TypeScript 7 applications need both versions installed. It is not included in the generated types. + +**Migration:** replace `import ts from "typescript"` with `import { ts } from "openapi-typescript"` in code that creates or manipulates the generator's AST. This includes `transform`, `postTransform`, and `transformProperty` callbacks and `ts.Node`/`ts.TypeNode` annotations. Do not mix AST nodes from a different compiler version: their `SyntaxKind` values may differ. This change does not require changing the compiler used to typecheck your application. + ::: tip Recommended For the best experience, use Node ESM by adding `"type": "module"` to `package.json` ([docs](https://nodejs.org/api/esm.html#enabling)) @@ -115,8 +121,7 @@ By default, openapiTS will generate `updated_at?: string;` because it’s not su ::: code-group ```ts [src/my-project.ts] -import openapiTS from "openapi-typescript"; -import ts from "typescript"; +import openapiTS, { ts } from "openapi-typescript"; const DATE = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Date")); // `Date` const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` @@ -167,8 +172,7 @@ Use the same pattern to transform the types: ::: code-group ```ts [src/my-project.ts] -import openapiTS from "openapi-typescript"; -import ts from "typescript"; +import openapiTS, { ts } from "openapi-typescript"; const BLOB = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Blob")); // `Blob` const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` @@ -220,8 +224,7 @@ Here we return an object with a schema property, which is the same as the above ::: code-group ```ts [src/my-project.ts] -import openapiTS from "openapi-typescript"; -import ts from "typescript"; +import openapiTS, { ts } from "openapi-typescript"; const BLOB = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Blob")); // `Blob` const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` @@ -297,8 +300,7 @@ components: ```ts [src/my-project.ts] import fs from "node:fs"; -import ts from "typescript"; -import openapiTS, { astToString } from "openapi-typescript"; +import openapiTS, { astToString, ts } from "openapi-typescript"; const ast = await openapiTS(mySchema, { transformProperty(property, schemaObject, options) { diff --git a/packages/openapi-typescript/build.config.ts b/packages/openapi-typescript/build.config.ts index fcba5abaa..8ec6fe153 100644 --- a/packages/openapi-typescript/build.config.ts +++ b/packages/openapi-typescript/build.config.ts @@ -5,6 +5,12 @@ export default defineBuildConfig({ declaration: "compatible", clean: true, sourcemap: true, + hooks: { + "rollup:dts:options"(_ctx, options) { + // Our CJS bundle exposes .default and named exports, not module.exports = default. + options.plugins = options.plugins.filter((plugin) => plugin.name !== "fix-dts-default-cjs-exports-plugin"); + }, + }, rollup: { // Ship CommonJS-compatible bundle emitCJS: true, diff --git a/packages/openapi-typescript/package.json b/packages/openapi-typescript/package.json index 2b322a039..c5b7ac897 100644 --- a/packages/openapi-typescript/package.json +++ b/packages/openapi-typescript/package.json @@ -54,13 +54,11 @@ "test:js": "vitest run", "test:exports": "pnpm run build && attw --pack .", "test:examples": "tsc -p tsconfig.examples.json --noEmit", + "test:typescript-7": "node ./scripts/test-typescript-7.mjs", "update:examples": "pnpm run build && pnpm run download:schemas && vite-node ./scripts/update-examples.ts", "prepublish": "pnpm run build", "version": "pnpm run build" }, - "peerDependencies": { - "typescript": "^5.x" - }, "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", @@ -68,6 +66,7 @@ "parse-json": "^8.3.0", "scule": "^1.3.0", "supports-color": "^10.2.2", + "typescript": "catalog:", "yargs-parser": "^21.1.1" }, "devDependencies": { @@ -76,7 +75,6 @@ "degit": "2.8.4", "execa": "catalog:", "strip-ansi": "7.2.0", - "typescript": "catalog:", "vite-node": "5.3.0" } } diff --git a/packages/openapi-typescript/scripts/test-typescript-7.mjs b/packages/openapi-typescript/scripts/test-typescript-7.mjs new file mode 100644 index 000000000..57a4f101a --- /dev/null +++ b/packages/openapi-typescript/scripts/test-typescript-7.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +// Test an installed tarball: workspace links can hide peer/declaration resolution bugs. +// Compile with the application's compiler, not the package's own TS 5 lint command. +const { values } = parseArgs({ + options: { + bun: { type: "boolean" }, + linker: { type: "string", default: "hoisted" }, + }, +}); +assert.ok(["hoisted", "isolated"].includes(values.linker)); +const cwd = mkdtempSync(join(tmpdir(), "openapi-typescript-ts7-")); +const pnpm = process.env.npm_execpath; +assert.ok(pnpm, "Run with pnpm run test:typescript-7"); +const run = (args, stdio = "inherit") => + values.bun + ? execFileSync("bun", args, { cwd, stdio }) + : execFileSync(process.execPath, [pnpm, ...args], { cwd, stdio }); +const install = [ + "install", + "--ignore-scripts", + ...(values.bun ? ["--linker", values.linker] : ["--strict-peer-dependencies"]), +]; + +try { + execFileSync(process.execPath, [pnpm, "pack", "--out", join(cwd, "openapi-typescript.tgz")], { + cwd: fileURLToPath(new URL("../", import.meta.url)), + stdio: "inherit", + }); + cpSync(new URL("../test/fixtures/typescript-7/", import.meta.url), cwd, { recursive: true }); + // Cover a fresh TS 7 install, then downgrade/upgrade without deleting the lockfile or node_modules. + for (const typescript of ["7.0.2", "5.9.3", "7.0.2"]) { + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ + private: true, + type: "module", + dependencies: { + "openapi-typescript": "file:./openapi-typescript.tgz", + typescript, + "@types/node": "25.6.0", + // Redocly's public declarations reference these undeclared type dependencies. + "@types/js-yaml": "4.0.9", + "json-schema-to-ts": "3.1.1", + }, + }), + ); + // pnpm defaults to frozen installs in CI, but this fixture intentionally changes compiler versions. + run([...install, ...(values.bun ? [] : ["--no-frozen-lockfile"])]); + run([...install, "--frozen-lockfile"]); + const version = execFileSync(process.execPath, ["node_modules/typescript/bin/tsc", "--version"], { cwd }); + assert.equal(version.toString().trim(), `Version ${typescript}`); + // Also check the application's PATH: installing the generator must not replace tsc. + const binVersion = run([values.bun ? "run" : "exec", "tsc", "--version"], "pipe"); + assert.equal(binVersion.toString().trim(), `Version ${typescript}`); + run([ + ...(values.bun ? ["--bun", "run"] : ["exec"]), + "openapi-typescript", + "schema.json", + "--output", + "schema.d.ts", + ]); + assert.match(readFileSync(join(cwd, "schema.d.ts"), "utf8"), /createdAt\?: string/); + execFileSync( + process.execPath, + [ + "node_modules/typescript/bin/tsc", + "--strict", + "--skipLibCheck", + "false", + "--module", + "NodeNext", + "--target", + "ES2022", + "esm.mts", + "cjs.cts", + ], + { cwd, stdio: "inherit" }, + ); + execFileSync(process.execPath, ["esm.mjs"], { cwd, stdio: "inherit" }); + execFileSync(process.execPath, ["cjs.cjs"], { cwd, stdio: "inherit" }); + if (values.bun) { + execFileSync("bun", ["esm.mjs"], { cwd, stdio: "inherit" }); + execFileSync("bun", ["cjs.cjs"], { cwd, stdio: "inherit" }); + } + } +} finally { + rmSync(cwd, { recursive: true, force: true }); +} diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index d042b81e4..182a5a614 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -7,6 +7,7 @@ import { debug, resolveRef, scanDiscriminators } from "./lib/utils.js"; import transformSchema from "./transform/index.js"; import type { GlobalContext, OpenAPI3, OpenAPITSOptions } from "./types.js"; +export { default as ts } from "typescript"; export * from "./lib/ts.js"; export * from "./lib/utils.js"; export { default as transformComponentsObject } from "./transform/components-object.js"; diff --git a/packages/openapi-typescript/test/fixtures/typescript-7/cjs.cts b/packages/openapi-typescript/test/fixtures/typescript-7/cjs.cts new file mode 100644 index 000000000..b5df2fd81 --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/typescript-7/cjs.cts @@ -0,0 +1,20 @@ +import assert = require("node:assert/strict"); +import fs = require("node:fs"); +import openapiTS = require("openapi-typescript"); + +async function main() { + const { ts, astToString } = openapiTS; + const ast: openapiTS.ts.Node[] = await openapiTS.default(fs.readFileSync("schema.json", "utf8"), { + transform(schema) { + if (schema.format === "date-time") { + return ts.factory.createTypeReferenceNode("Date"); + } + }, + }); + assert.ok(ast.some(ts.isInterfaceDeclaration)); + assert.match(astToString(ast), /createdAt\?: Date/); +} + +main().catch((error) => { + throw error; +}); diff --git a/packages/openapi-typescript/test/fixtures/typescript-7/esm.mts b/packages/openapi-typescript/test/fixtures/typescript-7/esm.mts new file mode 100644 index 000000000..bccf87e0c --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/typescript-7/esm.mts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import openapiTS, { astToString, ts, type OpenAPITSOptions } from "openapi-typescript"; +import type { components } from "./schema.js"; + +const require = createRequire(import.meta.url); +const generatorRequire = createRequire(require.resolve("openapi-typescript")); +assert.strictEqual(ts, generatorRequire("typescript")); +assert.strictEqual(ts, require("openapi-typescript").ts); +assert.match(ts.version, /^5\./); + +const person: components["schemas"]["Person"] = { name: "Ada" }; +assert.equal(person.name, "Ada"); +// @ts-expect-error Generated types must still reject invalid data under TypeScript 7. +const invalid: components["schemas"]["Person"] = { name: 123 }; + +const options: OpenAPITSOptions = { + transform(schema): ts.TypeNode | undefined { + if (schema.format === "date-time") { + return ts.factory.createTypeReferenceNode("Date"); + } + }, + postTransform(node): ts.TypeNode { + if (node.kind === ts.SyntaxKind.StringKeyword) { + return ts.factory.createUnionTypeNode([node, ts.factory.createLiteralTypeNode(ts.factory.createNull())]); + } + return node; + }, + transformProperty(property): ts.PropertySignature { + assert.ok(ts.isPropertySignature(property)); + return ts.factory.updatePropertySignature( + property, + [ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)], + property.name, + property.questionToken, + property.type, + ); + }, +}; +const ast: ts.Node[] = await openapiTS(new URL("./schema.json", import.meta.url), options); +assert.ok(ast.some(ts.isInterfaceDeclaration)); +const printer: ts.PrinterOptions = { newLine: ts.NewLineKind.LineFeed }; +const output = astToString(ast, { formatOptions: printer }); +assert.match(output, /readonly name: string \| null/); +assert.match(output, /readonly createdAt\?: Date/); diff --git a/packages/openapi-typescript/test/fixtures/typescript-7/schema.json b/packages/openapi-typescript/test/fixtures/typescript-7/schema.json new file mode 100644 index 000000000..063692580 --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/typescript-7/schema.json @@ -0,0 +1,16 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Compiler compatibility", "version": "1.0.0" }, + "components": { + "schemas": { + "Person": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + } + } + } + } +} diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index b22f8ad18..08f730481 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -1,6 +1,5 @@ import { fileURLToPath } from "node:url"; -import ts from "typescript"; -import openapiTS, { astToString, COMMENT_HEADER } from "../src/index.js"; +import openapiTS, { astToString, COMMENT_HEADER, ts } from "../src/index.js"; import type { OpenAPITSOptions } from "../src/types.js"; import type { TestCase } from "./test-helpers.js"; diff --git a/packages/openapi-typescript/tsconfig.json b/packages/openapi-typescript/tsconfig.json index 96c4e58bb..c06ef93b2 100644 --- a/packages/openapi-typescript/tsconfig.json +++ b/packages/openapi-typescript/tsconfig.json @@ -7,5 +7,5 @@ "types": ["vitest/globals"] }, "include": ["scripts", "src", "test", "*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "test/fixtures/typescript-7"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d3b87bc5..2bf2144f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: supports-color: specifier: ^10.2.2 version: 10.2.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 yargs-parser: specifier: ^21.1.1 version: 21.1.1 @@ -277,9 +280,6 @@ importers: strip-ansi: specifier: 7.2.0 version: 7.2.0 - typescript: - specifier: 'catalog:' - version: 5.9.3 vite-node: specifier: 5.3.0 version: 5.3.0(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)