Migrating from openapi-typescript
openapi-typesafe is a drop-in successor. A project on these packages moves by changing names:
| Before | After |
|---|---|
openapi-typescript 7.x (package and command) | openapi-typesafe |
openapi-fetch 0.x | openapi-typesafe-fetch |
openapi-react-query | openapi-typesafe-react-query |
openapi-typescript-helpers | openapi-typesafe-helpers |
All openapi-typesafe packages have one version number. The generated types are the same (except for the changes below), the CLI flags are the same, and redocly.yaml keeps its x-openapi-ts settings.
Coming from openapi-typescript 6.x? Follow the upstream guide to 7.x first, then this one.
Coming from openapi-fetch older than 0.17 or openapi-react-query older than 0.5.4? Upgrade them upstream first (openapi-fetch@0.17, openapi-react-query@0.5.4), run your type check and tests, then follow this guide. openapi-typesafe-fetch continues openapi-fetch 0.17 and openapi-typesafe-react-query continues openapi-react-query 0.5.4, so the changes before those versions are upstream's and are listed in the openapi-fetch changelog and the openapi-react-query changelog. For example, openapi-fetch 0.14 dropped the minified build (dist/index.min.js), and openapi-react-query 0.5.4 needs @tanstack/react-query 5.80 or newer. The openapi-react-query@1.0.0 on npm is older than 0.5.1; upgrade it to 0.5.4 too.
1. Run the migration tool
In the project root, with a clean git tree:
npx openapi-typesafe-migrate # report what would change
npx openapi-typesafe-migrate --write # change the filesIt renames the packages in every package.json and in pnpm catalogs, the module specifiers in your sources, and the openapi-typescript command in package.json scripts, shell scripts, CI files, Makefiles and Dockerfiles. It also writes the new spelling of CLI flags that openapi-typescript renamed. A legacy transform callback that only returns ts.factory types becomes type text with irCallbacks: true.
It reads the files git lists, so files that .gitignore ignores (build output, for example) stay as they are. Submodules and nested repositories are read with their own .gitignore.
Whatever it cannot change safely, it lists with the file and line: version specs such as latest, overrides, renamed peer dependencies, npx openapi-typescript@<version>, deep imports, strings that equal a package name outside an import (an object key or a bundler option, for example), swr-openapi, and callbacks it cannot rewrite. Every finding also goes to a log file whose path it prints. See the tool’s README for details.
Prefer to do it by hand? Rename the packages in the table above in package.json, imports and scripts; nothing else is required.
2. Install and regenerate once
npm install # or pnpm install, yarn, bun install
npm run generate # your script that runs openapi-typesafeWith Yarn 4 and enableImmutableInstalls (on by default in CI), this first install must change the lockfile: run yarn install --no-immutable once and commit yarn.lock.
Commit the regenerated files once. Unless your schemas have open string enums or one of the output fixes applies, the only changes are the comments that name the generator, first the header line:
/**
- * This file was auto-generated by openapi-typescript.
+ * This file was auto-generated by openapi-typesafe.
* Do not make direct changes to the file.
*/A schema with a discriminator also gets the generator's name in the comment of each added discriminator property: @description discriminator enum property added by openapi-typesafe.
If you check generated files in CI with --check, it fails until this commit lands, because the files on disk still carry the old header.
The run may print warnings that openapi-typescript did not print, while the types stay the same. Each one ends with a code, such as PATH_TEMPLATES_IDENTICAL for paths that differ only in their parameter names, or LINT_PROBLEM with the rule operation-operationId-unique for a repeated operationId. A warning does not change the output or the exit code; the list of warnings says what each one means.
3. Type check and test
Run your type check and tests. For the generator and the client, nothing else changes, with the exceptions below.
What is different
Node.js
openapi-typesafe needs Node.js 22.12 or newer (the client and helpers: Node.js 22). The CommonJS build loads ESM-only dependencies with require(), which Node.js supports without a flag from 22.12.
No TypeScript dependency
The generator does not need the typescript package. It prints types itself, so it runs next to TypeScript 7 (see TypeScript 7). typescript is an optional peer dependency, loaded only by the legacy callbacks below. Its range is >=5.4.0, the TypeScript versions openapi-typesafe supports; openapi-typescript said ^5.x. On TypeScript 5.0 to 5.3, npm reports a peer conflict (ERESOLVE): upgrade TypeScript to 5.4 or newer.
Node API returns IR
openapiTS() returns the declarations as IR objects (IrDeclaration[]), not TypeScript compiler nodes. astToString(await openapiTS(…)) works unchanged and prints the same text. Code that inspected or edited the returned ts.Nodes must use the IR objects instead. The helpers (tsUnion, NULL, STRING, …) and the exported transformers return IR too; they still accept TypeScript nodes as input. See Node.js API.
Legacy callbacks
transform, postTransform and transformProperty that work with TypeScript nodes keep working in 1.x, with a deprecation warning, as long as a TypeScript JavaScript API can be loaded: typescript 6 or earlier, or @typescript/typescript6 in a TypeScript 7 project. Move them to IR callbacks (irCallbacks: true), which return IR or plain type text such as "Date | null". The TypeScript-node forms are removed in 2.0.
swr-openapi
swr-openapi 5 has openapi-fetch (0.15 or newer) as a peer dependency and takes its client type from it. Alias openapi-fetch to openapi-typesafe-fetch with overrides or resolutions, as Libraries built on openapi-fetch shows; without the alias, passing it the openapi-typesafe-fetch client fails with TS2345. The tool's report reminds you of this when it finds swr-openapi.
Renamed CLI flags
As in openapi-typescript 7, the old spellings fail with an error that names the new one: --immutable-types and -it (use --immutable), --support-array-length (--array-length), --redoc (--redocly) and -ap (--additional-properties).
CLI exit codes and errors
Failures now exit with a documented code: 1 stale output with --check (as before), 2 usage or configuration, 3 input document, 4 output files, 70 unexpected error. Before, every failure exited with 1. See exit codes. --check without --output (and without apis in redocly.yaml) now fails with 2 and CLI_CHECK_OUTPUT_MISSING; before, it printed the types and passed without checking anything. Without a schema, with nothing piped into stdin (stdin is a terminal) and without apis in redocly.yaml, the CLI now fails with 2 and CLI_INPUT_MISSING; before, it waited for input with no prompt, which looked like a hang. An --output (or x-openapi-ts.output) that is a schema file the CLI reads now fails with 2 and CLI_OUTPUT_IS_INPUT and writes nothing; before, the types replaced the schema. APIs in redocly.yaml with the same x-openapi-ts.output now all fail with 2 and CLI_OUTPUT_DUPLICATE; before, the API that finished last silently won.
OpenAPI 3.2 documents get types for query and additionalOperations operations, itemSchema (an itemContent map next to content), in: querystring parameters and components.mediaTypes; openapi-typescript leaves them out. HttpMethod from openapi-typesafe-helpers includes "query", and the fetch client has QUERY(). A Record<HttpMethod, …> you write yourself needs a query entry. The path-based client no longer offers PARAMETERS(), which was typed but always failed. The types of these 3.2 additions are experimental in 1.x and may change in a minor release. See OpenAPI 3.2 support.
Errors thrown by the Node API are OpenAPITypesafeErrors with a stable code (see errors). The messages are unchanged. A file system error is kept as cause: check error.cause.code instead of error.code === "ENOENT".
Open string enums
A type: string enum with additionalProperties: true is typed with any other string added, so editors still suggest the values while other strings are accepted:
status: ("active" | "inactive") | (string & {}); // openapi-typescript 7.13.0: "active" | "inactive"This change was merged upstream after 7.13.0 was published (openapi-typescript#2415) and is on by default. It changes valid output, so check the types of such schemas after you regenerate.
Headers for remote documents
Headers from resolve.http.headers in redocly.yaml go over plain HTTP only to this machine (localhost, 127.0.0.0/8, ::1) or when the pattern starts with http://. A header for a plain HTTP server on another machine needs its pattern written as http://api.internal:8080/**. Headers are no longer sent on through a redirect to another origin.
Output fixes
Where openapi-typescript’s output did not compile, was unsafe or had wrong types, it changes:
--enumand--enum-valuesoutput that did not compile now does.--root-typeswith--enum: a root type alias that had the same name as an enum gets a suffix (ClusterState_2).--root-types: a root type alias that had the same name as a helper type of the file (a schema namedWithRequiredwith--root-types-no-schema-prefix) gets the same suffix (WithRequired_2).--root-typesnames that were not identifiers now are:--root-types-keep-casingwrites characters such as-as_(SchemaAddress_3), and--root-types-no-schema-prefixkeeps the prefix before a digit (Schema1) and before a name that TypeScript reserves, which only--root-types-keep-casingcan give (Schemavoid, notvoid).- Names and URLs from the document are escaped where they reach code (
--root-types,--path-params-as-types), so a document can no longer add code to the generated file. - The
injectoption is printed as written, so comments in it are kept. --make-paths-enum:ApiPathsmembers whose names collided (GetUserfor both/userand/user/{user_id}) did not compile. Colliding names built from the URL now name the path parameters (GetUserByUserId); names that still collide, such as a repeatedoperationId, get a number (listItems2). Names that did not collide stay the same.--enumand--make-paths-enum: values whose member names come out the same after invalid characters are replaced (reactions-+1andreactions--1are bothreactions_1) did not compile. A repeated name gets a number:reactions_1_2(a quoted name gets it inside the quotes). Names that did not collide stay the same.--exclude-deprecated: a deprecated schema that kept output still references (an array'sitems, a kept operation's response) was left out, so the output named a missingcomponents["schemas"]key. It is kept now; a deprecated schema that only dropped parts reference is still left out.--path-params-as-types: a template key such as`/apps/${string}`also matches/apps/regionsand`/apps/${string}/restart`, and that output did not compile (TS2411, TS2413) when their types do not fit the template's. When a path surely does not fit (a method or a parameter location one has and the other has not, or an operation or response schema that lacks a required property of the template's),pathsis now an intersection type alias,{ … } & { [path: `/apps/${string}`]: … }, and every path keeps its type. Output that compiled stays the same,export interface pathsincluded.- JSON Schema
$defs(OpenAPI 3.1): a schema's$defsmember is optional ($defs?: { … }), and a$refinto it readsNonNullable<components["schemas"]["Foo"]["$defs"]>["bar"]. The rootexport type $defshas one member per subschema; before, it wasRecord<string, never>and#/$defs/<name>refs werenever. - A schema that
allOfs a discriminated union which lists that schema (Cat: { allOf: [Pet] },Pet: { oneOf: [Cat, Dog] }) referred to itself throughOmit<Pet>and did not compile (TS2502, upstream #1665). It is now typed with the keywords ofPetitself, without the union. WhenPetinherits the discriminator throughallOf, neitherPetnor its keywords get the value"Pet", which every member would contradict.
All other output stays the same. The compatibility report checks this on 1,820 real-world documents with 28 option sets (50,960 cases): 99.99% of the outputs are the same byte for byte and 100% have the same types. Its golden output is the 7.13 code at tag fork-base, which already has the open string enums change.
New options
These are off by default, so the output does not change until you turn one on.
--binary-as-blob(binaryAsBlob): raw binary strings (format: binary, or acontentMediaTypethat is not text) becomeBlobinstead ofstring. It replaces theBlobtransformrecipe; the names follow openapi-typescript PR #2878.--fix(fix): opt-in fixes of the output of valid schemas, by name orall. They become the default in 2.0. The first one,allof-required, makes the keys of anallOfmember with onlyrequiredrequired instead of addingRecord<string, never>.anyof-oneof-requiredmakes ananyOforoneOfofrequired-only variants the parent with each variant's keys required, instead ofunknown.anyof-propertiesintersectsanyOfwith its siblingpropertiesinstead of adding them to the union.array-length-tuplescorrects the--array-lengthtuples (minItemstomaxItemsitems, nested arrays,readonly).discriminator-valuesgives a schema that a discriminatormappingnames one enum with the mapped values, instead of also adding its schema name, which made the propertynever.root-type-numbering(with--root-types --root-types-no-schema-prefix) gives each schema of one root name an alias: the later keeps the name, the earlier get_2,_3, instead of none.unevaluated-propertiestypes the keys that no other keyword declares with theunevaluatedPropertiesschema, instead of ignoring it.discriminator-valuesalso keeps a schema's ownmappingwhen it inherits a discriminator, adds no second property to a member that declares its value, and with--enumtypes the value as a member of the owner's enum (PetType.Cat).allof-discriminated-uniontypes anallOf$refto a discriminated union as the union, withoutOmitor an invented value.discriminator-member-requiredkeepsWithRequirednext to anallOf$refto a discriminator member.anyof-oneof-descriptionslists the descriptions ofanyOfandoneOfmembers in the JSDoc.dedupe-enum-names(with--enum --dedupe-enums) names a shared enum by a rule that does not depend on the document order.exploded-query-objectsmakes the properties of an exploded object query parameter keys ofquery, as they are sent.nullable-enums(with--enum) declares an enum that listsnulland adds| nullto its type.path-param-type-names(with--path-params-as-types) gives a path parameter namednumber,stringorbooleanits own placeholder.prefix-itemsmakes aprefixItemstuple open, with the elements afterminItemsoptional.read-write-refsmakes a propertyreadonlywhen the schema it references isreadOnly.ref-pointersgives a$refinto another schema's keywords the type it points at also where an index signature made the original compile asunknown.
Libraries built on openapi-fetch
A library that imports types from openapi-fetch, such as swr-openapi, needs openapi-fetch aliased to openapi-typesafe-fetch with overrides (npm, bun, pnpm-workspace.yaml) or resolutions (yarn). See Libraries built on openapi-fetch.
Client fixes
- A string body with a non-JSON
Content-Typesuch astext/plainis sent as is, not JSON-encoded. - A path parameter that makes a
.or..segment is rejected before the request is sent. - openapi-typesafe-react-query fails on every non-OK response, also one without a body.
- Middleware request ids come from a counter instead of
Math.random(). - A path that starts with
//once the path parameters are in (an empty first parameter) is rejected before the request is sent, since a browser sends it to another host. - An error response with an empty body gives
error: undefined, also withoutContent-Length; it gave"". - openapi-typesafe-react-query:
useInfiniteQueryhas its own query key,[...queryKey, "infinite"], so it no longer shares a cache entry withuseQuery.[method, path]still matches it as a prefix; code that reads or writes the entry with the exact key must append"infinite". - A
Blob,File,ArrayBuffer, typed array orURLSearchParamsbody is sent as is; before, it was JSON-encoded to{}. - A nested object in an
application/x-www-form-urlencodedbody throws instead of being sent as[object Object]; write abodySerializerfor the format your server reads. - An object body with
Content-Type: multipart/form-datais sent asFormData(one part per field), not as JSON, and amultipart/form-dataheader without a boundary no longer breaks aFormDataupload. - A path that already has a query string gets
&beforeparams.query, not a second?. defaultBodySerializerdeclares the type it returns instead ofstring: a raw body as its own type,undefinedforundefined, andFormDataorstring | FormDatafor an object with a multipart or an unknownContent-Type. An object without headers, or with headers written as a record, is stillstringunless theContent-Typeis multipart. Code that used the result as astringin the other cases, or in a globalbodySerializer(itsbodyis typedundefined), no longer compiles: checktypeof result === "string".- A
Dateparameter is sent as ISO 8601 (toISOString()) in every query and path style. Before, aDateon its own or in a path was left out (it is an object without members), and one in an array or aformobject was sent asString(date), a local time text. - A query parameter that serializes to nothing (an empty array or object,
null) no longer leaves a stray&(a=1&&b=2), andnullitems of exploded arrays and objects are left out too. - A success body that is not JSON (for example a proxy's HTML page) throws a
SyntaxErrorthat names the status, theContent-Typeand the start of the body and says whichparseAsto set (parseAs: "text"for atext/*response), with the parser's error ascause; before, it was the parser's bareSyntaxError. Bodies are still parsed as JSON unlessparseAsis set, as in openapi-fetch. See response body. TRACE()is unchanged, and its platform differences are documented: browsers, Node.js and Deno reject it, Bun sends it and returns an empty body. See TRACE.ReadableandWritablekeep function types, so aBloborDatein response data keeps its methods (data.file.arrayBuffer()).
The full list, with the upstream issues each change closes, is in the changelog.