Skip to main content

openapi-to-typescript

@spartaficial/openapi-to-typescript turns an OpenAPI 3.x specification into plain TypeScript declarations: one type per schema, enums in the shape you choose, and a type for every query string, header set, request body and response an operation declares. This page is for developers who want those types in application or test code without any runtime library, and it covers every option the generator has.

What it generates​

Take a specification, simple.yaml, with four schemas:

openapi: 3.0.0
info:
title: Simple Test API
version: 1.0.0
components:
schemas:
User:
type: object
description: A user in the system
properties:
id:
type: string
format: uuid
description: The unique identifier for the user
name:
type: string
description: The user's full name
email:
type: string
format: email
description: The user's email address
age:
type: integer
description: The user's age in years
isActive:
type: boolean
description: Whether the user account is active
required: [id, name]
Status:
type: string
enum: [active, inactive, pending]
Address:
type: object
properties:
street: { type: string }
city: { type: string }
postalCode: { type: string }
required: [street, city]
Company:
type: object
properties:
name: { type: string }
address:
$ref: "#/components/schemas/Address"
employees:
type: array
items:
$ref: "#/components/schemas/User"
required: [name]

and the smallest possible configuration:

// openapi-to-typescript.config.ts
import { defineConfig } from "@spartaficial/openapi-to-typescript";

export default defineConfig({
specs: [{ input: "simple.yaml", outputTypes: "src/api/types.ts" }],
});

npx openapi-to-typescript writes src/api/types.ts:

// Auto-generated by @spartaficial/openapi-to-typescript
// API: Simple Test API
// Version: 1.0.0
// Do not edit this file manually

/**
* TypeScript Types Generation Statistics
* Generated from: simple.yaml
* Total schemas: 4
*/

/**
* A user in the system
*/
export type User = {
/** The unique identifier for the user @format uuid */
id: string;
/** The user's full name */
name: string;
/** The user's email address @format email */
email?: string;
/** The user's age in years */
age?: number;
/** Whether the user account is active */
isActive?: boolean;
};

export const Status = {
Active: "active",
Inactive: "inactive",
Pending: "pending",
} as const;
export type Status = (typeof Status)[keyof typeof Status];

export type Address = {
street: string;
city: string;
postalCode?: string;
};

export type Company = {
name: string;
address?: Address;
employees?: User[];
};

A few things are worth noticing. Every property that is not listed under required becomes optional. A $ref becomes the name of the referenced type, and schemas are written in dependency order so Address and User are declared before Company uses them. Descriptions become JSDoc, and a format is recorded as an @format tag because TypeScript has no narrower type for it; the Zod generator is where formats are enforced. The Status enum is emitted as a constant object with a derived type, which is the default enumFormat; the other two shapes are shown under Enum formats. The statistics comment at the top is controlled by showStats.

Install​

npm install --save-dev @spartaficial/openapi-to-typescript
npx openapi-to-typescript init
npx openapi-to-typescript

The package needs Node.js 20 or later and has no peer dependencies; see requirements.md. init asks for the specification file, the output path, the enum format, whether you want a TypeScript or JSON configuration file, and whether to include a defaults block with includeDescriptions and showStats switched on. It writes openapi-to-typescript.config.ts (or .json) in the current directory, and refuses to overwrite an existing one without confirmation.

Running npx openapi-to-typescript with no arguments finds that configuration file and generates every spec in it; --config <path> points at a file elsewhere. There is no generate subcommand: the bare command generates, init scaffolds, and --help and --version do what they say. Generation needs a licence key in SPARTAFICIAL_LICENSE_KEY; nothing about compiling or shipping the generated file does. See licensing.md. The full command reference is in cli.md.

Configure​

A configuration file always has the same shape: an optional defaults block, a specs array with at least one entry, and an optional executionMode. Each spec needs input and outputTypes; everything else can live in defaults and be overridden per spec. The file format, discovery rules and merge order are described in configuration-files.md.

import { defineConfig } from "@spartaficial/openapi-to-typescript";

export default defineConfig({
defaults: {
enumFormat: "union",
includeDescriptions: true,
showStats: false,
},
specs: [
{ name: "public", input: "specs/public.yaml", outputTypes: "src/api/public.ts" },
{
name: "admin",
input: "specs/admin.yaml",
outputTypes: "src/api/admin.ts",
enumFormat: "enum",
ignoreHeaders: ["authorization", "x-trace-*"],
operationFilters: { excludeDeprecated: true },
},
],
executionMode: "parallel",
});

Beyond the shared set, this generator adds three options. The complete table, with types and defaults for every key, is options-typescript.md.

OptionValuesDefaultWhat it does
enumFormatenum, union, const-objectconst-objectHow a schema with enum is declared. See Enum formats.
ignoreHeadersheader names or glob patternsnoneDrops the named headers from the *HeaderParams types. Matching is case-insensitive, so authorization also removes Authorization.
schemaTypeall, request, responseallWhich side of an exchange the types describe: request omits readOnly properties, response omits writeOnly ones. See Request and response shapes.

The default for enumFormat is worth a note: init writes the format you choose into the configuration file explicitly, so the const-object default only applies when the key is absent.

The shared options behave the same in every generator and are explained on their concept pages:

  • includeDescriptions (default true), showStats (default true), showWarnings (default true), fileHeader and batchSize are general output and run controls; see configuration-files.md.
  • prefix, suffix, stripSchemaPrefix, stripPathPrefix and useOperationId (default true here) shape the generated names; see naming.md.
  • operationFilters restricts which operations, and therefore which schemas, are generated; see operation-filters.md.
  • defaultNullable and schemaType decide nullability and direction; see request-and-response-shapes.md.
  • refMappings resolves $refs to other documents; see specs-and-references.md.

executionMode defaults to parallel for this generator. The specification must be OpenAPI 3.x and must declare at least one schema under components.schemas; anything else fails validation before a file is written.

Use the output​

The generated file is plain TypeScript with no imports, so it compiles anywhere TypeScript does and adds nothing to a bundle. Import the types where you build requests or read responses:

import type { Company, Status, User } from "./api/types";

const status: Status = "active";

function newest(users: User[]): User | undefined {
return users.at(-1);
}

With the default const-object format the enum is also a value, so Status.Active works at run time and Object.values(Status) lists every member. Commit the generated file, or regenerate it in CI; either way the file is yours and contains no licence logic.

The other generators build on this one: openapi-to-zod writes its types file with it when outputZodSchemas is set, and openapi-to-k6 uses it for the types its client and service import. The options described here mean the same thing there.

Enum formats​

Each schema with an enum is declared in the shape enumFormat selects. With this input:

components:
schemas:
StringEnum:
type: string
description: A string-based enumeration
enum: [active, inactive, pending]
NumericEnum:
type: integer
enum: [0, 1, 2]
BooleanEnum:
type: boolean
enum: [true, false]

enumFormat: "const-object" (the default) produces an object and a type of the same name:

export const StringEnum = {
Active: "active",
Inactive: "inactive",
Pending: "pending",
} as const;
export type StringEnum = (typeof StringEnum)[keyof typeof StringEnum];

export const NumericEnum = {
Value0: 0,
Value1: 1,
Value2: 2,
} as const;
export type NumericEnum = (typeof NumericEnum)[keyof typeof NumericEnum];

enumFormat: "union" produces a literal union, which is the lightest option and the one to pick when you never need the values at run time:

export type StringEnum = "active" | "inactive" | "pending";
export type NumericEnum = 0 | 1 | 2;

enumFormat: "enum" produces a TypeScript enum:

export enum StringEnum {
Active = "active",
Inactive = "inactive",
Pending = "pending",
}

export enum NumericEnum {
Value0 = 0,
Value1 = 1,
Value2 = 2,
}

Member names are derived from the values: strings become PascalCase (active to Active, string_value to StringValue), numbers are prefixed with Value, and duplicates after normalisation are made unique. A boolean enum is always a union (true | false) because neither an enum nor a constant object can express it. A nullable enum appends | null to the derived type in the union and const-object formats; in the enum format, where that is not possible, a companion alias is added:

export enum Tone {
Formal = "formal",
Casual = "casual",
}
/** Nullable version of Tone enum */
export type ToneNullable = Tone | null;

An enum used inline in a property, rather than declared as its own schema, is always rendered as a literal union (role?: "admin" | "user" | "guest") regardless of enumFormat.

Composition, nullability and defaults​

The generator maps JSON Schema composition onto TypeScript's own operators. From this specification:

components:
schemas:
Employee:
allOf:
- $ref: "#/components/schemas/BaseEntity"
- $ref: "#/components/schemas/PersonBase"
- type: object
properties:
employeeId: { type: string }
department: { type: string }
salary: { type: number }
required: [employeeId, department]
Pet:
oneOf:
- $ref: "#/components/schemas/Cat"
- $ref: "#/components/schemas/Dog"
PetWithDiscriminator:
oneOf:
- $ref: "#/components/schemas/CatWithType"
- $ref: "#/components/schemas/DogWithType"
discriminator:
propertyName: type
mapping:
cat: "#/components/schemas/CatWithType"
dog: "#/components/schemas/DogWithType"
NullableString:
anyOf:
- type: string
- type: "null"
ParentBillableResource:
type: object
allOf:
- $ref: "#/components/schemas/BaseEntity"

it generates:

export type Employee = BaseEntity & PersonBase & { employeeId: string; department: string; salary?: number };

export type Pet = Cat | Dog;

export type CatWithType = {
type: "cat";
meow: boolean;
};

export type PetWithDiscriminator = CatWithType | DogWithType;

export type NullableString = string | null;

export type ParentBillableResource = BaseEntity;

allOf becomes an intersection, oneOf and anyOf become unions, and a one-member enum on a discriminator property becomes a literal type. Schemas named only in a discriminator.mapping are added to the union too, so the type agrees with what the Zod schema accepts. A const becomes a literal, prefixItems becomes a tuple type, an array without items becomes unknown[], and an object without properties becomes Record<string, unknown> or Record<string, T> when additionalProperties names a schema. An object that declares both properties and additionalProperties keeps its index signature as an intersection. A not that excludes literals from a primitive is rendered with Exclude<>; any other not is widened to unknown with a warning, because TypeScript cannot express it.

Nullability follows the specification in both of its dialects. nullable: true (OpenAPI 3.0) and type: ["string", "null"] (OpenAPI 3.1) both produce | null, and required and optional are kept apart from nullable:

export type User = {
id: string;
name: string;
email: string | null; // required, nullable
phone?: string | null; // optional, nullable
address?: Address | null; // nullable $ref
};

export type NullableArray = string[] | null;

defaultNullable: true makes every property nullable unless the schema says otherwise, for specifications written for tooling that assumed that convention; see request-and-response-shapes.md for the exact rules.

A property default does not change the type or the optionality, because the same alias serves the values you send and the values you receive. It is recorded in the JSDoc instead, next to the description and the @deprecated tag where applicable:

export type OrderLine = {
sku: string;
/** Number of units @default 1 */
qty?: number;
/** @default "piece" */
unit?: "piece" | "box";
};

Setting includeDescriptions: false drops the description text from schemas and properties; the @format, @default and @deprecated tags are kept, because they document behaviour rather than prose.

Request and response shapes​

A schema that uses readOnly or writeOnly describes two different objects: the one the client sends and the one the server returns. schemaType picks which one the types describe.

components:
schemas:
Account:
type: object
properties:
id:
type: string
readOnly: true
password:
type: string
writeOnly: true
name:
type: string
required: [name]
schemaTypeGenerated Account
all (default){ id?: string; password?: string; name: string }
request{ password?: string; name: string }
response{ id?: string; name: string }

A common arrangement is two specs over the same input: one with schemaType: "request" writing requests.ts, one with schemaType: "response" writing responses.ts. The option can be set in defaults as well as per spec. The concept and its interaction with the other generators are explained in request-and-response-shapes.md.

Operation types​

Besides the component schemas, the generator declares a type for everything an operation sends or receives that is not already a named schema. The names are built from the operation name followed by a fixed suffix:

Declared in the operationGenerated typeNotes
Query parameters<Operation>QueryParamsOne property per parameter; required follows the parameter's required.
Header parameters<Operation>HeaderParamsAlways string and always optional. Headers matched by ignoreHeaders are left out.
Inline request body schema<Operation>RequestA body that is a $ref reuses the component type instead. Several content types get a suffix each.
Inline response schema<Operation>ResponseSeveral 2xx responses get a status suffix each; several content types get a content suffix.

Parameters declared on the path item and parameters reached through a $ref into components.parameters are included. For the operation GET /users with operationId: listUsers, two query parameters and one header, the output is:

// Operation Types (Query Params, Headers, Request Bodies, Responses)

/**
* Query parameters for listUsers
*/
export type ListUsersQueryParams = {
/** Page number */
page?: number;
/** Items per page */
limit?: number;
};

/**
* Header parameters for listUsers
*/
export type ListUsersHeaderParams = {
/** Request tracking ID */
"X-Request-ID"?: string;
};

/**
* Response for listUsers (200)
*/
export type ListUsersResponse = { data?: User[]; total?: number };

The operation name is the operationId when there is one, because useOperationId defaults to true for this generator. With useOperationId: false it is derived from the method and path instead, so the same operation yields GetUsersQueryParams, and GET /organizations/{orgId}/members yields GetOrganizationsByOrgIdMembersQueryParams. stripPathPrefix shortens path-derived names, and prefix and suffix are applied to every generated type name, component and operation alike. The rules are collected in naming.md.

Property names that are not valid identifiers are quoted, so a parameter called filter[status] becomes "filter[status]"?: "open" | "closed" | "pending".

Filtering operations​

operationFilters restricts generation to a subset of the API. It removes the operations that do not match, and with them every component schema that no surviving operation references, directly or through other schemas. When filters are active the statistics block reports what was dropped:

/**
* TypeScript Types Generation Statistics
* Generated from: operations.yaml
* Total schemas: 6
* Operation Filtering:
* Total operations: 8
* Included operations: 4
* Filtered operations: 4
* - By tags: 3
* - By deprecated: 1
*/

Path filters are matched against the path as written in the specification, before stripPathPrefix is applied. A filter that matches nothing produces a warning rather than a silently empty file. The filter keys and their precedence are described in operation-filters.md.

Warnings​

Warnings are collected during a run and printed together at the end, prefixed with the package name. They cover a $ref that points at a schema that does not exist or at a document that could not be read, two schemas or operations whose names normalise to the same identifier (the file still contains both declarations, so the warning names both sources), an ignoreHeaders pattern or operation filter that matched nothing, and a not schema that had to be widened. Set showWarnings: false to suppress them.

Programmatic API​

Everything the CLI does is available from the package's main entry point.

import {
TypeScriptGenerator,
defineConfig,
loadConfig,
mergeConfigWithDefaults,
} from "@spartaficial/openapi-to-typescript";

// One spec, written to disk
new TypeScriptGenerator({
input: "simple.yaml",
outputTypes: "src/api/types.ts",
enumFormat: "union",
}).generate();

// The same, returned as a string without touching the file system
const code = new TypeScriptGenerator({ input: "simple.yaml", outputTypes: "types.ts" }).generateString();

// Or drive a configuration file yourself
const config = await loadConfig("./openapi-to-typescript.config.ts");
for (const spec of mergeConfigWithDefaults(config)) {
new TypeScriptGenerator(spec).generate();
}

TypeScriptGenerator takes a TypeScriptGeneratorOptions object, the same keys as one spec entry in the configuration file, and reads and validates the specification in its constructor. generate() writes outputTypes, creating directories as needed, and prints the path it wrote; generateString() returns the file contents. Both print collected warnings. The constructor checks the licence lazily on first use, as described in licensing.md.

defineConfig(config) returns its argument unchanged and exists for type checking and editor completion in .ts configuration files. loadConfig(configPath?) finds and validates a configuration file and returns a Promise<ConfigFile>; mergeConfigWithDefaults(config) expands the defaults block into each spec. mergeCliWithConfig(spec, cliOptions) overlays options on a spec, and the Zod schemas the loader validates with, TypeScriptGeneratorOptionsSchema, TypeScriptDefaultsSchema and TypeScriptSpecificOptionsSchema, are exported for anyone embedding the configuration format. executeBatch and getBatchExitCode run several generators the way the CLI does. The building blocks generateEnum, generateTypeDeclaration and formatTypeProperty are exported for other generators built on this one.

Errors are instances of the classes below, all of which extend GeneratorError and carry a code string and a context object:

ClassThrown when
ConfigurationErrorinput is missing, or generate() is called without outputTypes.
SpecValidationErrorThe file is empty, is not OpenAPI 3.x, or declares no components.schemas.
SchemaGenerationErrorA schema could not be converted; schemaName says which.
CircularReferenceErrorA circular reference could not be handled; referencePath lists the cycle.
FileOperationErrorThe output could not be written; filePath says where.
ConfigValidationErrorA configuration file failed validation (CLI and loadConfig); configPath says which file.
CliOptionsErrorCommand-line options were inconsistent.

The exported types are TypeScriptGeneratorOptions, DefaultOptions, SpecOptions, ConfigFile, EnumFormat, ExecutionMode, ResolvedOptions, OpenAPISpec, and the generator-building types EnumGeneratorOptions, EnumGeneratorResult, TypeGeneratorOptions, TypeGeneratorResult, BatchExecutionSummary and Generator.