Building a Custom OpenAPI 3.1 TypeScript Type Generator This is a substantial project. I'll build a complete, publishable npm library that parses OpenAPI 3.1 specs and generates split TypeScript type files.
Project Structure text
openapi-typegen/ ├── package.json ├── tsconfig.json ├── tsconfig.build.json ├── .eslintrc.json ├── .prettierrc ├── .gitignore ├── .npmignore ├── README.md ├── LICENSE ├── bin/ │ └── cli.ts ├── src/ │ ├── index.ts │ ├── parser/ │ │ ├── index.ts │ │ ├── openapi-parser.ts │ │ ├── schema-resolver.ts │ │ └── ref-resolver.ts │ ├── generator/ │ │ ├── index.ts │ │ ├── type-generator.ts │ │ ├── components-generator.ts │ │ ├── paths-generator.ts │ │ ├── operations-generator.ts │ │ ├── parameters-generator.ts │ │ ├── responses-generator.ts │ │ ├── request-bodies-generator.ts │ │ └── helpers.ts │ ├── emitter/ │ │ ├── index.ts │ │ ├── file-emitter.ts │ │ └── formatter.ts │ ├── types/ │ │ ├── openapi.ts │ │ └── config.ts │ └── utils/ │ ├── naming.ts │ ├── logger.ts │ └── fs.ts └── tests/ ├── fixtures/ │ └── petstore.json ├── parser.test.ts ├── generator.test.ts └── e2e.test.ts
- Package Configuration JSON
// package.json { "name": "openapi-split-typegen", "version": "1.0.0", "description": "Generate split TypeScript types from OpenAPI 3.1 specs — components, paths, operations, parameters, responses, and more in separate files.", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { "openapi-split-typegen": "dist/bin/cli.js" }, "files": [ "dist", "README.md", "LICENSE" ], "scripts": { "build": "tsc -p tsconfig.build.json", "dev": "ts-node src/index.ts", "cli": "ts-node bin/cli.ts", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src//*.ts", "format": "prettier --write "src//*.ts"", "prepublishOnly": "npm run build && npm test", "clean": "rm -rf dist" }, "keywords": [ "openapi", "typescript", "codegen", "types", "openapi3.1", "swagger", "split", "generator" ], "author": "Your Name", "license": "MIT", "dependencies": { "yaml": "^2.3.4", "glob": "^10.3.10", "prettier": "^3.2.4", "commander": "^12.0.0", "chalk": "^4.1.2" }, "devDependencies": { "@types/node": "^20.11.0", "typescript": "^5.3.3", "vitest": "^1.2.0", "ts-node": "^10.9.2", "@types/glob": "^8.1.0" }, "engines": { "node": ">=18.0.0" } } JSON
// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "outDir": "./dist", "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "declarationMap": true, "sourceMap": true, "moduleResolution": "node" }, "include": ["src//*", "bin//*"], "exclude": ["node_modules", "dist", "tests"] } JSON
// tsconfig.build.json { "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "." }, "include": ["src//*", "bin//*"], "exclude": ["node_modules", "dist", "tests"] } 2. Core Types TypeScript
// src/types/openapi.ts
/** OpenAPI 3.1 Specification Types */
export interface OpenAPIDocument { openapi: string; info: InfoObject; servers?: ServerObject[]; paths?: PathsObject; webhooks?: Record<string, PathItemObject | ReferenceObject>; components?: ComponentsObject; security?: SecurityRequirementObject[]; tags?: TagObject[]; externalDocs?: ExternalDocumentationObject; }
export interface InfoObject { title: string; version: string; description?: string; termsOfService?: string; contact?: ContactObject; license?: LicenseObject; summary?: string; }
export interface ContactObject { name?: string; url?: string; email?: string; }
export interface LicenseObject { name: string; identifier?: string; url?: string; }
export interface ServerObject { url: string; description?: string; variables?: Record<string, ServerVariableObject>; }
export interface ServerVariableObject { enum?: string[]; default: string; description?: string; }
export interface PathsObject { [path: string]: PathItemObject; }
export interface PathItemObject { $ref?: string; summary?: string; description?: string; get?: OperationObject; put?: OperationObject; post?: OperationObject; delete?: OperationObject; options?: OperationObject; head?: OperationObject; patch?: OperationObject; trace?: OperationObject; servers?: ServerObject[]; parameters?: (ParameterObject | ReferenceObject)[]; }
export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace';
export const HTTP_METHODS: HttpMethod[] = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
export interface OperationObject { tags?: string[]; summary?: string; description?: string; externalDocs?: ExternalDocumentationObject; operationId?: string; parameters?: (ParameterObject | ReferenceObject)[]; requestBody?: RequestBodyObject | ReferenceObject; responses?: ResponsesObject; callbacks?: Record<string, CallbackObject | ReferenceObject>; deprecated?: boolean; security?: SecurityRequirementObject[]; servers?: ServerObject[]; }
export interface ParameterObject { name: string; in: 'query' | 'header' | 'path' | 'cookie'; description?: string; required?: boolean; deprecated?: boolean; allowEmptyValue?: boolean; schema?: SchemaObject | ReferenceObject; style?: string; explode?: boolean; content?: Record<string, MediaTypeObject>; }
export interface RequestBodyObject { description?: string; content: Record<string, MediaTypeObject>; required?: boolean; }
export interface MediaTypeObject { schema?: SchemaObject | ReferenceObject; example?: unknown; examples?: Record<string, ExampleObject | ReferenceObject>; encoding?: Record<string, EncodingObject>; }
export interface EncodingObject { contentType?: string; headers?: Record<string, HeaderObject | ReferenceObject>; style?: string; explode?: boolean; allowReserved?: boolean; }
export interface ResponsesObject { [statusCode: string]: ResponseObject | ReferenceObject; }
export interface ResponseObject { description: string; headers?: Record<string, HeaderObject | ReferenceObject>; content?: Record<string, MediaTypeObject>; links?: Record<string, LinkObject | ReferenceObject>; }
export interface HeaderObject { description?: string; required?: boolean; deprecated?: boolean; schema?: SchemaObject | ReferenceObject; }
export interface LinkObject { operationRef?: string; operationId?: string; parameters?: Record<string, unknown>; requestBody?: unknown; description?: string; server?: ServerObject; }
export interface CallbackObject { [expression: string]: PathItemObject | ReferenceObject; }
export interface ComponentsObject { schemas?: Record<string, SchemaObject | ReferenceObject>; responses?: Record<string, ResponseObject | ReferenceObject>; parameters?: Record<string, ParameterObject | ReferenceObject>; examples?: Record<string, ExampleObject | ReferenceObject>; requestBodies?: Record<string, RequestBodyObject | ReferenceObject>; headers?: Record<string, HeaderObject | ReferenceObject>; securitySchemes?: Record<string, SecuritySchemeObject | ReferenceObject>; links?: Record<string, LinkObject | ReferenceObject>; callbacks?: Record<string, CallbackObject | ReferenceObject>; pathItems?: Record<string, PathItemObject | ReferenceObject>; }
export interface SchemaObject { // JSON Schema 2020-12 (OpenAPI 3.1) type?: string | string[]; format?: string; title?: string; description?: string; default?: unknown; enum?: unknown[]; const?: unknown;
// Numeric multipleOf?: number; maximum?: number; exclusiveMaximum?: number; minimum?: number; exclusiveMinimum?: number;
// String maxLength?: number; minLength?: number; pattern?: string;
// Array items?: SchemaObject | ReferenceObject; prefixItems?: (SchemaObject | ReferenceObject)[]; maxItems?: number; minItems?: number; uniqueItems?: boolean; contains?: SchemaObject | ReferenceObject;
// Object properties?: Record<string, SchemaObject | ReferenceObject>; patternProperties?: Record<string, SchemaObject | ReferenceObject>; additionalProperties?: boolean | SchemaObject | ReferenceObject; required?: string[]; maxProperties?: number; minProperties?: number; propertyNames?: SchemaObject | ReferenceObject;
// Composition allOf?: (SchemaObject | ReferenceObject)[]; oneOf?: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[]; not?: SchemaObject | ReferenceObject;
// Conditionals if?: SchemaObject | ReferenceObject; then?: SchemaObject | ReferenceObject; else?: SchemaObject | ReferenceObject;
// OpenAPI specific extensions discriminator?: DiscriminatorObject; xml?: XmlObject; externalDocs?: ExternalDocumentationObject; example?: unknown; examples?: unknown[]; deprecated?: boolean; readOnly?: boolean; writeOnly?: boolean; nullable?: boolean; // 3.0 compat
// 3.1: true/false schemas [key: string]: unknown; }
export interface DiscriminatorObject { propertyName: string; mapping?: Record<string, string>; }
export interface XmlObject { name?: string; namespace?: string; prefix?: string; attribute?: boolean; wrapped?: boolean; }
export interface ReferenceObject { $ref: string; summary?: string; description?: string; }
export interface ExampleObject { summary?: string; description?: string; value?: unknown; externalValue?: string; }
export interface SecuritySchemeObject { type: 'apiKey' | 'http' | 'mutualTLS' | 'oauth2' | 'openIdConnect'; description?: string; name?: string; in?: 'query' | 'header' | 'cookie'; scheme?: string; bearerFormat?: string; flows?: OAuthFlowsObject; openIdConnectUrl?: string; }
export interface OAuthFlowsObject { implicit?: OAuthFlowObject; password?: OAuthFlowObject; clientCredentials?: OAuthFlowObject; authorizationCode?: OAuthFlowObject; }
export interface OAuthFlowObject { authorizationUrl?: string; tokenUrl?: string; refreshUrl?: string; scopes: Record<string, string>; }
export interface SecurityRequirementObject { [name: string]: string[]; }
export interface TagObject { name: string; description?: string; externalDocs?: ExternalDocumentationObject; }
export interface ExternalDocumentationObject { description?: string; url: string; }
export function isReferenceObject(obj: unknown): obj is ReferenceObject { return typeof obj === 'object' && obj !== null && '$ref' in obj; } TypeScript
// src/types/config.ts
export interface GeneratorConfig { /** Path to the OpenAPI spec file (JSON or YAML) */ input: string;
/** Output directory for generated files */ output: string;
/** Which file groups to generate */ split: SplitOptions;
/** Formatting options */ format?: FormatOptions;
/** Naming conventions */ naming?: NamingOptions;
/** Whether to generate barrel exports (index.ts) */ barrelExports?: boolean;
/** Whether to add JSDoc comments from descriptions */ jsdoc?: boolean;
/** Whether to export as type (using export type) */
exportType?: boolean;
/** Custom header to prepend to each generated file */ header?: string;
/** Enable verbose logging */ verbose?: boolean;
/** Whether to generate readonly types */ immutable?: boolean;
/** How to handle additionalProperties when not specified */
additionalProperties?: 'strict' | 'permissive';
/** Path alias for imports between generated files */ pathAlias?: string; }
export interface SplitOptions { /** Generate schemas (components/schemas) */ schemas?: boolean;
/** Generate path types */ paths?: boolean;
/** Generate operation types (grouped by operationId or tag) */ operations?: boolean;
/** Generate parameter types */ parameters?: boolean;
/** Generate response types */ responses?: boolean;
/** Generate request body types */ requestBodies?: boolean;
/** Generate header types */ headers?: boolean;
/** Generate a complete merged file as well */ complete?: boolean;
/** Split schemas into individual files (one per schema) */ individualSchemas?: boolean;
/** Group operations by tag into separate files */ groupByTag?: boolean; }
export interface FormatOptions { /** Use prettier to format output */ prettier?: boolean;
/** Prettier config path */ prettierConfig?: string;
/** Indentation (spaces) */ indent?: number;
/** Line width for prettier */ printWidth?: number;
/** Use single quotes */ singleQuote?: boolean;
/** Trailing commas */ trailingComma?: 'all' | 'es5' | 'none';
/** Semicolons */ semi?: boolean; }
export interface NamingOptions { /** Convention for type names */ typeNaming?: 'PascalCase' | 'camelCase' | 'preserve';
/** Convention for file names */ fileNaming?: 'kebab-case' | 'camelCase' | 'PascalCase' | 'snake_case';
/** Prefix for all generated types */ typePrefix?: string;
/** Suffix for all generated types */ typeSuffix?: string;
/** Prefix for enum types */ enumPrefix?: string;
/** Suffix for operation types */ operationSuffix?: string;
/** Suffix for response types */ responseSuffix?: string;
/** Suffix for request body types */ requestBodySuffix?: string;
/** Suffix for parameter types */ parameterSuffix?: string; }
export const DEFAULT_CONFIG: Required = { input: '', output: './generated', split: { schemas: true, paths: true, operations: true, parameters: true, responses: true, requestBodies: true, headers: true, complete: false, individualSchemas: false, groupByTag: false, }, format: { prettier: true, prettierConfig: '', indent: 2, printWidth: 100, singleQuote: true, trailingComma: 'all', semi: true, }, naming: { typeNaming: 'PascalCase', fileNaming: 'kebab-case', typePrefix: '', typeSuffix: '', enumPrefix: '', operationSuffix: '', responseSuffix: '', requestBodySuffix: '', parameterSuffix: '', }, barrelExports: true, jsdoc: true, exportType: true, header: '/* Auto-generated by openapi-split-typegen. Do not edit manually. */', verbose: false, immutable: false, additionalProperties: 'permissive', pathAlias: '', }; 3. Utilities TypeScript
// src/utils/naming.ts
import { NamingOptions } from '../types/config';
export function toPascalCase(str: string): string { return str .replace(/[^a-zA-Z0-9$_]/g, ' ') .split(/[\s_-]+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); }
export function toCamelCase(str: string): string { const pascal = toPascalCase(str); return pascal.charAt(0).toLowerCase() + pascal.slice(1); }
export function toKebabCase(str: string): string { return str .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .replace(/[^a-zA-Z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); }
export function toSnakeCase(str: string): string { return str .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .replace(/[^a-zA-Z0-9]/g, '') .replace(/+/g, '') .replace(/^|_$/g, '') .toLowerCase(); }
export function sanitizeTypeName(name: string): string { // Remove characters invalid in TS identifiers let sanitized = name.replace(/[^a-zA-Z0-9$_]/g, ''); // Ensure it doesn't start with a number if (/^[0-9]/.test(sanitized)) { sanitized = '' + sanitized; } return sanitized; }
export function applyTypeNaming(name: string, options: NamingOptions): string { let result: string;
switch (options.typeNaming) { case 'PascalCase': result = toPascalCase(name); break; case 'camelCase': result = toCamelCase(name); break; case 'preserve': default: result = sanitizeTypeName(name); break; }
if (options.typePrefix) { result = options.typePrefix + result; } if (options.typeSuffix) { result = result + options.typeSuffix; }
return result; }
export function applyFileNaming(name: string, options: NamingOptions): string { switch (options.fileNaming) { case 'kebab-case': return toKebabCase(name); case 'camelCase': return toCamelCase(name); case 'PascalCase': return toPascalCase(name); case 'snake_case': return toSnakeCase(name); default: return toKebabCase(name); } }
export function operationIdToTypeName(operationId: string): string { return toPascalCase(operationId); }
export function pathToTypeName(path: string, method: string): string { const parts = path .split('/') .filter(Boolean) .map((part) => { if (part.startsWith('{') && part.endsWith('}')) { return 'By' + toPascalCase(part.slice(1, -1)); } return toPascalCase(part); });
return toPascalCase(method) + parts.join(''); }
export function httpStatusToName(status: string): string { const statusMap: Record<string, string> = { '200': 'Ok', '201': 'Created', '202': 'Accepted', '204': 'NoContent', '301': 'MovedPermanently', '302': 'Found', '304': 'NotModified', '400': 'BadRequest', '401': 'Unauthorized', '403': 'Forbidden', '404': 'NotFound', '405': 'MethodNotAllowed', '409': 'Conflict', '410': 'Gone', '422': 'UnprocessableEntity', '429': 'TooManyRequests', '500': 'InternalServerError', '502': 'BadGateway', '503': 'ServiceUnavailable', default: 'Default', };
return statusMap[status] || Status${status};
}
export function isValidIdentifier(str: string): boolean { return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str); }
export function quotePropertyName(name: string): string {
if (isValidIdentifier(name)) {
return name;
}
return '${name.replace(/'/g, "\\'")}';
}
TypeScript
// src/utils/logger.ts
import chalk from 'chalk';
export class Logger { private verbose: boolean;
constructor(verbose: boolean = false) { this.verbose = verbose; }
info(message: string): void { console.log(chalk.blue('ℹ'), message); }
success(message: string): void { console.log(chalk.green('✔'), message); }
warn(message: string): void { console.log(chalk.yellow('⚠'), message); }
error(message: string): void { console.error(chalk.red('✖'), message); }
debug(message: string): void { if (this.verbose) { console.log(chalk.gray('⊙'), chalk.gray(message)); } }
group(title: string): void {
console.log();
console.log(chalk.bold.cyan(▸ ${title}));
}
file(filename: string): void { console.log(chalk.gray(' →'), chalk.white(filename)); } } TypeScript
// src/utils/fs.ts
import * as fs from 'fs'; import * as path from 'path';
export function ensureDir(dirPath: string): void { fs.mkdirSync(dirPath, { recursive: true }); }
export function writeFile(filePath: string, content: string): void { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, content, 'utf-8'); }
export function readFile(filePath: string): string { return fs.readFileSync(filePath, 'utf-8'); }
export function fileExists(filePath: string): boolean { return fs.existsSync(filePath); }
export function resolveFilePath(input: string): string { if (path.isAbsolute(input)) { return input; } return path.resolve(process.cwd(), input); } 4. Parser TypeScript
// src/parser/ref-resolver.ts
import { ReferenceObject, isReferenceObject, OpenAPIDocument, SchemaObject } from '../types/openapi';
export interface ResolvedRef { name: string; category: string; value: unknown; }
export class RefResolver { private document: OpenAPIDocument; private cache: Map<string, unknown> = new Map();
constructor(document: OpenAPIDocument) { this.document = document; }
/**
- Resolve a $ref string to the actual object in the document */ resolve<T = unknown>(ref: string): T { if (this.cache.has(ref)) { return this.cache.get(ref) as T; }
const parts = ref.replace(/^#\//, '').split('/');
let current: unknown = this.document;
for (const part of parts) {
const decodedPart = decodeURIComponent(part.replace(/~1/g, '/').replace(/~0/g, '~'));
if (current && typeof current === 'object' && current !== null) {
current = (current as Record<string, unknown>)[decodedPart];
} else {
throw new Error(`Cannot resolve $ref: ${ref} — failed at part "${decodedPart}"`);
}
}
if (current === undefined) {
throw new Error(`$ref not found: ${ref}`);
}
this.cache.set(ref, current);
return current as T;
}
/**
- Deep resolve - follows chains of $ref */ deepResolve<T = unknown>(objOrRef: T | ReferenceObject): T { if (isReferenceObject(objOrRef)) { const resolved = this.resolve<T | ReferenceObject>(objOrRef.$ref); return this.deepResolve(resolved); } return objOrRef as T; }
/**
- Parse a
$ref string into its component parts */ parseRef(ref: string): { category: string; name: string } { // e.g. #/components/schemas/Pet → { category: 'schemas', name: 'Pet' } const match = ref.match(/^#/components/([^/]+)/(.+)$ /); if (match) { return { category: match[1], name: decodeURIComponent(match[2].replace(/~1/g, '/').replace(/0/g, '')), }; }
// For non-component refs, use the last segment
const parts = ref.replace(/^#\//, '').split('/');
return {
category: parts.length > 1 ? parts[parts.length - 2] : 'unknown',
name: decodeURIComponent(parts[parts.length - 1].replace(/~1/g, '/').replace(/~0/g, '~')),
};
}
/**
- Convert a $ref to the import type name it would reference */ refToTypeName(ref: string): string { const { name } = this.parseRef(ref); return name; }
/**
- Collect all $refs used in a schema/object */ collectRefs(obj: unknown, refs: Set = new Set()): Set { if (obj === null || obj === undefined || typeof obj !== 'object') { return refs; }
if (isReferenceObject(obj)) {
refs.add(obj.$ref);
return refs;
}
if (Array.isArray(obj)) {
for (const item of obj) {
this.collectRefs(item, refs);
}
} else {
for (const value of Object.values(obj as Record<string, unknown>)) {
this.collectRefs(value, refs);
}
}
return refs;
} } TypeScript
// src/parser/schema-resolver.ts
import { SchemaObject, ReferenceObject, isReferenceObject, } from '../types/openapi'; import { RefResolver } from './ref-resolver';
export interface TypeInfo { typescript: string; imports: ImportInfo[]; isEnum: boolean; enumValues?: unknown[]; }
export interface ImportInfo { typeName: string; ref: string; category: string; fileName?: string; }
export class SchemaResolver { private refResolver: RefResolver; private immutable: boolean; private additionalPropertiesMode: 'strict' | 'permissive';
constructor( refResolver: RefResolver, immutable: boolean = false, additionalPropertiesMode: 'strict' | 'permissive' = 'permissive' ) { this.refResolver = refResolver; this.immutable = immutable; this.additionalPropertiesMode = additionalPropertiesMode; }
/**
- Convert a schema or ref to its TypeScript type string */ schemaToType( schemaOrRef: SchemaObject | ReferenceObject | undefined | boolean, imports: ImportInfo[] = [], depth: number = 0, parentRequired: boolean = false ): string { if (schemaOrRef === undefined) { return 'unknown'; }
if (typeof schemaOrRef === 'boolean') {
return schemaOrRef ? 'unknown' : 'never';
}
// Handle $ref
if (isReferenceObject(schemaOrRef)) {
return this.handleRef(schemaOrRef, imports);
}
const schema = schemaOrRef;
// Handle const
if (schema.const !== undefined) {
return this.valueToLiteral(schema.const);
}
// Handle enum
if (schema.enum) {
return this.handleEnum(schema);
}
// Handle composition
if (schema.allOf) {
return this.handleAllOf(schema.allOf, imports, depth);
}
if (schema.oneOf) {
return this.handleOneOf(schema.oneOf, imports, depth);
}
if (schema.anyOf) {
return this.handleAnyOf(schema.anyOf, imports, depth);
}
if (schema.not) {
// TypeScript doesn't have negation types, use unknown
return 'unknown';
}
// Handle type
const type = schema.type;
if (Array.isArray(type)) {
return this.handleMultiType(type, schema, imports, depth);
}
switch (type) {
case 'string':
return this.handleString(schema);
case 'number':
case 'integer':
return 'number';
case 'boolean':
return 'boolean';
case 'null':
return 'null';
case 'array':
return this.handleArray(schema, imports, depth);
case 'object':
return this.handleObject(schema, imports, depth);
default:
// No type specified — infer from properties
if (schema.properties || schema.additionalProperties) {
return this.handleObject(schema, imports, depth);
}
if (schema.items) {
return this.handleArray(schema, imports, depth);
}
return 'unknown';
}
}
private handleRef(ref: ReferenceObject, imports: ImportInfo[]): string { const parsed = this.refResolver.parseRef(ref.$ref); const typeName = parsed.name;
imports.push({
typeName,
ref: ref.$ref,
category: parsed.category,
});
return typeName;
}
private handleEnum(schema: SchemaObject): string { return schema .enum!.map((value) => this.valueToLiteral(value)) .join(' | '); }
private handleAllOf( schemas: (SchemaObject | ReferenceObject)[], imports: ImportInfo[], depth: number ): string { const types = schemas.map((s) => this.schemaToType(s, imports, depth + 1)); if (types.length === 1) return types[0]; return types.map((t) => this.wrapIfComplex(t)).join(' & '); }
private handleOneOf( schemas: (SchemaObject | ReferenceObject)[], imports: ImportInfo[], depth: number ): string { const types = schemas.map((s) => this.schemaToType(s, imports, depth + 1)); if (types.length === 1) return types[0]; return types.join(' | '); }
private handleAnyOf( schemas: (SchemaObject | ReferenceObject)[], imports: ImportInfo[], depth: number ): string { // anyOf is semantically similar to oneOf for TS purposes return this.handleOneOf(schemas, imports, depth); }
private handleMultiType( types: string[], schema: SchemaObject, imports: ImportInfo[], depth: number ): string { const tsTypes = types.map((t) => { if (t === 'null') return 'null'; const subSchema = { ...schema, type: t }; return this.schemaToType(subSchema, imports, depth); }); return tsTypes.join(' | '); }
private handleString(schema: SchemaObject): string { if (schema.format === 'date-time' || schema.format === 'date') { return 'string'; // Keep as string, user can override } if (schema.format === 'binary') { return 'Blob | File'; } return 'string'; }
private handleArray(
schema: SchemaObject,
imports: ImportInfo[],
depth: number
): string {
// Tuple (prefixItems)
if (schema.prefixItems && schema.prefixItems.length > 0) {
const tupleTypes = schema.prefixItems.map((item) =>
this.schemaToType(item, imports, depth + 1)
);
const prefix = this.immutable ? 'readonly ' : '';
return ${prefix}[${tupleTypes.join(', ')}];
}
// Regular array
const itemType = schema.items
? this.schemaToType(schema.items, imports, depth + 1)
: 'unknown';
if (this.immutable) {
return `readonly ${this.wrapIfComplex(itemType)}[]`;
}
return `${this.wrapIfComplex(itemType)}[]`;
}
private handleObject( schema: SchemaObject, imports: ImportInfo[], depth: number ): string { const properties = schema.properties; const required = new Set(schema.required || []); const lines: string[] = []; const indent = ' '.repeat(depth + 1); const closingIndent = ' '.repeat(depth);
if (properties) {
for (const [propName, propSchema] of Object.entries(properties)) {
const isRequired = required.has(propName);
const propType = this.schemaToType(propSchema, imports, depth + 1);
const readonlyPrefix = this.immutable ? 'readonly ' : '';
const optionalSuffix = isRequired ? '' : '?';
const quotedName = this.needsQuoting(propName)
? `'${propName.replace(/'/g, "\\'")}'`
: propName;
lines.push(`${indent}${readonlyPrefix}${quotedName}${optionalSuffix}: ${propType};`);
}
}
// Handle additionalProperties
const additionalProperties = schema.additionalProperties;
if (additionalProperties !== undefined) {
if (additionalProperties === true) {
lines.push(`${indent}[key: string]: unknown;`);
} else if (typeof additionalProperties === 'object' && additionalProperties !== null) {
const addPropType = this.schemaToType(additionalProperties, imports, depth + 1);
lines.push(`${indent}[key: string]: ${addPropType};`);
}
// false means no additional properties, don't add index signature
} else if (this.additionalPropertiesMode === 'permissive' && properties) {
// When not specified but has properties, in permissive mode, allow extras
// Actually, don't add index sig by default for cleanliness
}
if (lines.length === 0) {
// Empty object or just additionalProperties
if (additionalProperties === true || additionalProperties === undefined) {
return 'Record<string, unknown>';
}
if (typeof additionalProperties === 'object') {
const valueType = this.schemaToType(additionalProperties, imports, depth + 1);
return `Record<string, ${valueType}>`;
}
return 'Record<string, never>';
}
return `{\n${lines.join('\n')}\n${closingIndent}}`;
}
valueToLiteral(value: unknown): string {
if (value === null) return 'null';
if (typeof value === 'string') return '${value.replace(/'/g, "\\'")}';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (typeof value === 'object') return JSON.stringify(value);
return 'unknown';
}
private needsQuoting(name: string): boolean { return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); }
private wrapIfComplex(type: string): string {
// Wrap union/intersection types in parens when used as array element
if (type.includes(' | ') || type.includes(' & ')) {
return (${type});
}
return type;
}
}
TypeScript
// src/parser/openapi-parser.ts
import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'yaml'; import { OpenAPIDocument, PathsObject, ComponentsObject, OperationObject, PathItemObject, HTTP_METHODS, HttpMethod, } from '../types/openapi'; import { RefResolver } from './ref-resolver'; import { Logger } from '../utils/logger';
export interface ParsedOperation { path: string; method: HttpMethod; operation: OperationObject; operationId: string; tags: string[]; }
export interface ParsedDocument { document: OpenAPIDocument; refResolver: RefResolver; operations: ParsedOperation[]; }
export class OpenAPIParser { private logger: Logger;
constructor(logger: Logger) { this.logger = logger; }
async parse(inputPath: string): Promise {
this.logger.info(Parsing OpenAPI spec: ${inputPath});
const absolutePath = path.resolve(inputPath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`File not found: ${absolutePath}`);
}
const content = fs.readFileSync(absolutePath, 'utf-8');
const ext = path.extname(absolutePath).toLowerCase();
let document: OpenAPIDocument;
if (ext === '.yaml' || ext === '.yml') {
document = yaml.parse(content) as OpenAPIDocument;
} else {
document = JSON.parse(content) as OpenAPIDocument;
}
this.validateDocument(document);
const refResolver = new RefResolver(document);
const operations = this.extractOperations(document);
this.logger.success(
`Parsed: ${Object.keys(document.paths || {}).length} paths, ` +
`${operations.length} operations, ` +
`${Object.keys(document.components?.schemas || {}).length} schemas`
);
return { document, refResolver, operations };
}
private validateDocument(doc: OpenAPIDocument): void { if (!doc.openapi) { throw new Error('Missing "openapi" field. Is this an OpenAPI spec?'); }
const version = doc.openapi;
if (!version.startsWith('3.1') && !version.startsWith('3.0')) {
this.logger.warn(
`Spec version ${version} detected. This tool is optimized for 3.1 but may work with 3.0.`
);
}
if (!doc.info) {
throw new Error('Missing "info" field in OpenAPI spec.');
}
}
private extractOperations(document: OpenAPIDocument): ParsedOperation[] { const operations: ParsedOperation[] = []; const paths = document.paths || {}; let operationCounter = 0;
for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!operation) continue;
operationCounter++;
const operationId =
operation.operationId || `${method}${this.pathToOperationId(pathStr)}_${operationCounter}`;
operations.push({
path: pathStr,
method,
operation,
operationId,
tags: operation.tags || ['default'],
});
}
}
return operations;
}
private pathToOperationId(pathStr: string): string { return pathStr .split('/') .filter(Boolean) .map((segment) => { if (segment.startsWith('{') && segment.endsWith('}')) { return 'By' + segment.charAt(1).toUpperCase() + segment.slice(2, -1); } return segment.charAt(0).toUpperCase() + segment.slice(1); }) .join(''); } } TypeScript
// src/parser/index.ts
export { OpenAPIParser, ParsedDocument, ParsedOperation } from './openapi-parser'; export { RefResolver } from './ref-resolver'; export { SchemaResolver, TypeInfo, ImportInfo } from './schema-resolver'; 5. Generators TypeScript
// src/generator/helpers.ts
import { ImportInfo } from '../parser/schema-resolver'; import { NamingOptions, GeneratorConfig } from '../types/config'; import { applyTypeNaming, applyFileNaming } from '../utils/naming';
export interface GeneratedFile { fileName: string; content: string; category: string; }
export interface TypeDeclaration { name: string; originalName: string; typeDef: string; imports: ImportInfo[]; jsdoc?: string; isExported: boolean; }
export function buildImportStatements( imports: ImportInfo[], currentCategory: string, config: GeneratorConfig, categoryFileMap: Map<string, string> ): string { // Group imports by category/source file const importsBySource = new Map<string, Set>();
for (const imp of imports) { const typeName = applyTypeNaming(imp.typeName, config.naming || {}); const sourceFile = resolveImportPath(imp, currentCategory, config, categoryFileMap);
if (!sourceFile) continue; // Same file, no import needed
if (!importsBySource.has(sourceFile)) {
importsBySource.set(sourceFile, new Set());
}
importsBySource.get(sourceFile)!.add(typeName);
}
const lines: string[] = []; const sortedSources = Array.from(importsBySource.keys()).sort();
for (const source of sortedSources) {
const types = Array.from(importsBySource.get(source)!).sort();
const typeKeyword = config.exportType ? 'type ' : '';
lines.push(import ${typeKeyword}{ ${types.join(', ')} } from '${source}';);
}
return lines.join('\n'); }
function resolveImportPath( imp: ImportInfo, currentCategory: string, config: GeneratorConfig, categoryFileMap: Map<string, string> ): string | null { // If same category, no import needed (types in same file) if (imp.category === currentCategory) { return null; }
const naming = config.naming || {}; const targetFile = categoryFileMap.get(imp.category);
if (!targetFile) {
// Fall back: use category name as file name
const fileName = applyFileNaming(imp.category, naming);
if (config.pathAlias) {
return ${config.pathAlias}/${fileName};
}
return ./${fileName};
}
if (config.pathAlias) {
return ${config.pathAlias}/${targetFile};
}
return ./${targetFile};
}
export function buildJSDoc(description?: string, deprecated?: boolean, extra?: Record<string, string>): string { const lines: string[] = [];
if (description) {
// Handle multi-line descriptions
const descLines = description.split('\n');
for (const line of descLines) {
lines.push( * ${line});
}
}
if (deprecated) {
lines.push( * @deprecated);
}
if (extra) {
for (const [tag, value] of Object.entries(extra)) {
lines.push( * @${tag} ${value});
}
}
if (lines.length === 0) return '';
return /**\n${lines.join('\n')}\n */\n;
}
export function deduplicateImports(imports: ImportInfo[]): ImportInfo[] {
const seen = new Map<string, ImportInfo>();
for (const imp of imports) {
const key = ${imp.category}:${imp.typeName};
if (!seen.has(key)) {
seen.set(key, imp);
}
}
return Array.from(seen.values());
}
TypeScript
// src/generator/type-generator.ts
import { SchemaObject, ReferenceObject, isReferenceObject } from '../types/openapi'; import { SchemaResolver, ImportInfo } from '../parser/schema-resolver'; import { RefResolver } from '../parser/ref-resolver'; import { GeneratorConfig } from '../types/config'; import { applyTypeNaming } from '../utils/naming'; import { buildJSDoc, TypeDeclaration } from './helpers';
export class TypeGenerator { private schemaResolver: SchemaResolver; private refResolver: RefResolver; private config: GeneratorConfig;
constructor( refResolver: RefResolver, config: GeneratorConfig ) { this.refResolver = refResolver; this.config = config; this.schemaResolver = new SchemaResolver( refResolver, config.immutable || false, config.additionalProperties || 'permissive' ); }
generateSchemaType( name: string, schema: SchemaObject | ReferenceObject ): TypeDeclaration { const naming = this.config.naming || {}; const typeName = applyTypeNaming(name, naming); const imports: ImportInfo[] = [];
if (isReferenceObject(schema)) {
const refType = this.schemaResolver.schemaToType(schema, imports, 0);
return {
name: typeName,
originalName: name,
typeDef: refType,
imports,
jsdoc: '',
isExported: true,
};
}
// Check if this should be an enum-like type
if (schema.enum && this.isStringEnum(schema)) {
return this.generateEnumType(name, typeName, schema, imports);
}
const typeDef = this.schemaResolver.schemaToType(schema, imports, 0);
const jsdoc = this.config.jsdoc
? buildJSDoc(schema.description, schema.deprecated)
: '';
return {
name: typeName,
originalName: name,
typeDef,
imports,
jsdoc,
isExported: true,
};
}
private generateEnumType( originalName: string, typeName: string, schema: SchemaObject, imports: ImportInfo[] ): TypeDeclaration { const enumName = (this.config.naming?.enumPrefix || '') + typeName; const typeDef = schema .enum!.map((value) => this.schemaResolver.valueToLiteral(value)) .join(' | ');
const jsdoc = this.config.jsdoc
? buildJSDoc(schema.description, schema.deprecated)
: '';
return {
name: enumName,
originalName,
typeDef,
imports,
jsdoc,
isExported: true,
};
}
private isStringEnum(schema: SchemaObject): boolean { if (schema.type === 'string' || !schema.type) { return schema.enum!.every((v) => typeof v === 'string' || v === null); } return false; }
getSchemaResolver(): SchemaResolver { return this.schemaResolver; } } TypeScript
// src/generator/components-generator.ts
import { OpenAPIDocument, SchemaObject, ReferenceObject, isReferenceObject } from '../types/openapi'; import { TypeGenerator } from './type-generator'; import { GeneratedFile, TypeDeclaration, buildImportStatements, deduplicateImports } from './helpers'; import { GeneratorConfig } from '../types/config'; import { RefResolver } from '../parser/ref-resolver'; import { applyFileNaming, applyTypeNaming } from '../utils/naming';
export class ComponentsGenerator { private typeGenerator: TypeGenerator; private config: GeneratorConfig; private refResolver: RefResolver;
constructor(typeGenerator: TypeGenerator, refResolver: RefResolver, config: GeneratorConfig) { this.typeGenerator = typeGenerator; this.config = config; this.refResolver = refResolver; }
generateSchemas(document: OpenAPIDocument): GeneratedFile[] { const schemas = document.components?.schemas; if (!schemas) return [];
if (this.config.split?.individualSchemas) {
return this.generateIndividualSchemas(schemas);
}
return [this.generateSingleSchemasFile(schemas)];
}
private generateSingleSchemasFile( schemas: Record<string, SchemaObject | ReferenceObject> ): GeneratedFile { const declarations: TypeDeclaration[] = [];
for (const [name, schema] of Object.entries(schemas)) {
const decl = this.typeGenerator.generateSchemaType(name, schema);
declarations.push(decl);
}
const content = this.renderDeclarations(declarations, 'schemas');
const naming = this.config.naming || {};
const fileName = applyFileNaming('schemas', naming);
return {
fileName,
content,
category: 'schemas',
};
}
private generateIndividualSchemas( schemas: Record<string, SchemaObject | ReferenceObject> ): GeneratedFile[] { const files: GeneratedFile[] = []; const naming = this.config.naming || {};
for (const [name, schema] of Object.entries(schemas)) {
const decl = this.typeGenerator.generateSchemaType(name, schema);
const content = this.renderDeclarations([decl], 'schemas');
const fileName = `schemas/${applyFileNaming(name, naming)}`;
files.push({
fileName,
content,
category: 'schemas',
});
}
return files;
}
private renderDeclarations( declarations: TypeDeclaration[], currentCategory: string ): string { const lines: string[] = [];
// Collect all imports
const allImports = declarations.flatMap((d) => d.imports);
const dedupedImports = deduplicateImports(allImports);
// Filter out self-references (schemas referencing other schemas in same file)
const externalImports = dedupedImports.filter(
(imp) => imp.category !== currentCategory
);
// For schemas in the same file, no import needed
// But for individual schemas, we need cross-imports
if (this.config.split?.individualSchemas) {
const schemaImports = dedupedImports.filter(
(imp) => imp.category === 'schemas'
);
// Each individual file needs to import from sibling schema files
if (schemaImports.length > 0) {
const naming = this.config.naming || {};
for (const imp of schemaImports) {
const fileName = applyFileNaming(imp.typeName, naming);
const typeName = applyTypeNaming(imp.typeName, naming);
const typeKeyword = this.config.exportType ? 'type ' : '';
lines.push(`import ${typeKeyword}{ ${typeName} } from './${fileName}';`);
}
}
}
if (externalImports.length > 0) {
const categoryFileMap = this.buildCategoryFileMap();
const importStatements = buildImportStatements(
externalImports,
currentCategory,
this.config,
categoryFileMap
);
if (importStatements) {
lines.push(importStatements);
}
}
if (lines.length > 0) {
lines.push('');
}
// Render type declarations
for (const decl of declarations) {
if (decl.jsdoc) {
lines.push(decl.jsdoc);
}
const exportKeyword = decl.isExported
? this.config.exportType
? 'export type'
: 'export type'
: 'type';
lines.push(`${exportKeyword} ${decl.name} = ${decl.typeDef};`);
lines.push('');
}
return lines.join('\n');
}
private buildCategoryFileMap(): Map<string, string> { const naming = this.config.naming || {}; const map = new Map<string, string>(); map.set('schemas', applyFileNaming('schemas', naming)); map.set('responses', applyFileNaming('responses', naming)); map.set('parameters', applyFileNaming('parameters', naming)); map.set('requestBodies', applyFileNaming('request-bodies', naming)); map.set('headers', applyFileNaming('headers', naming)); return map; } } TypeScript
// src/generator/paths-generator.ts
import { OpenAPIDocument, PathItemObject, OperationObject, ParameterObject, ReferenceObject, ResponseObject, RequestBodyObject, isReferenceObject, HttpMethod, HTTP_METHODS, } from '../types/openapi'; import { TypeGenerator } from './type-generator'; import { SchemaResolver, ImportInfo } from '../parser/schema-resolver'; import { RefResolver } from '../parser/ref-resolver'; import { GeneratorConfig } from '../types/config'; import { GeneratedFile, buildImportStatements, buildJSDoc, deduplicateImports, } from './helpers'; import { applyFileNaming, applyTypeNaming, quotePropertyName, httpStatusToName } from '../utils/naming';
export class PathsGenerator { private typeGenerator: TypeGenerator; private schemaResolver: SchemaResolver; private refResolver: RefResolver; private config: GeneratorConfig;
constructor(typeGenerator: TypeGenerator, refResolver: RefResolver, config: GeneratorConfig) { this.typeGenerator = typeGenerator; this.refResolver = refResolver; this.config = config; this.schemaResolver = typeGenerator.getSchemaResolver(); }
generate(document: OpenAPIDocument): GeneratedFile[] { const paths = document.paths; if (!paths) return [];
const naming = this.config.naming || {};
const lines: string[] = [];
const allImports: ImportInfo[] = [];
lines.push('/** All API paths */');
lines.push('export interface Paths {');
for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
const pathType = this.generatePathItemType(pathStr, pathItem, allImports);
const quotedPath = quotePropertyName(pathStr);
lines.push(` ${quotedPath}: {`);
lines.push(pathType);
lines.push(' };');
}
lines.push('}');
lines.push('');
// Also generate individual path parameter types
const pathParamTypes = this.generatePathParamTypes(document, allImports);
if (pathParamTypes) {
lines.push(pathParamTypes);
}
// Build import statements
const dedupedImports = deduplicateImports(allImports);
const categoryFileMap = this.buildCategoryFileMap();
const importStatements = buildImportStatements(dedupedImports, 'paths', this.config, categoryFileMap);
let content = '';
if (importStatements) {
content += importStatements + '\n\n';
}
content += lines.join('\n');
const fileName = applyFileNaming('paths', naming);
return [
{
fileName,
content,
category: 'paths',
},
];
}
private generatePathItemType( pathStr: string, pathItem: PathItemObject, imports: ImportInfo[] ): string { const lines: string[] = [];
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!operation) continue;
const operationType = this.generateOperationInlineType(operation, imports);
lines.push(` ${method}: ${operationType};`);
}
// Path-level parameters
if (pathItem.parameters) {
const paramType = this.generateParametersType(pathItem.parameters, imports);
lines.push(` parameters: ${paramType};`);
}
return lines.join('\n');
}
private generateOperationInlineType( operation: OperationObject, imports: ImportInfo[] ): string { const parts: string[] = [];
// Parameters
if (operation.parameters && operation.parameters.length > 0) {
const paramType = this.generateParametersType(operation.parameters, imports);
parts.push(`parameters: ${paramType}`);
}
// Request body
if (operation.requestBody) {
const bodyType = this.generateRequestBodyInline(operation.requestBody, imports);
parts.push(`requestBody: ${bodyType}`);
}
// Responses
if (operation.responses) {
const respType = this.generateResponsesInline(operation.responses, imports);
parts.push(`responses: ${respType}`);
}
if (parts.length === 0) {
return '{}';
}
return `{\n ${parts.join(';\n ')};\n }`;
}
private generateParametersType( parameters: (ParameterObject | ReferenceObject)[], imports: ImportInfo[] ): string { const grouped: Record<string, string[]> = { query: [], path: [], header: [], cookie: [], };
for (const paramOrRef of parameters) {
let param: ParameterObject;
if (isReferenceObject(paramOrRef)) {
const parsed = this.refResolver.parseRef(paramOrRef.$ref);
imports.push({
typeName: parsed.name,
ref: paramOrRef.$ref,
category: parsed.category,
});
continue; // Skip inline generation for refs
} else {
param = paramOrRef;
}
const paramType = param.schema
? this.schemaResolver.schemaToType(param.schema, imports, 0)
: 'unknown';
const optional = param.required ? '' : '?';
grouped[param.in]?.push(`${quotePropertyName(param.name)}${optional}: ${paramType}`);
}
const parts: string[] = [];
for (const [location, params] of Object.entries(grouped)) {
if (params.length > 0) {
parts.push(`${location}: { ${params.join('; ')} }`);
}
}
if (parts.length === 0) return '{}';
return `{ ${parts.join('; ')} }`;
}
private generateRequestBodyInline( bodyOrRef: RequestBodyObject | ReferenceObject, imports: ImportInfo[] ): string { if (isReferenceObject(bodyOrRef)) { return this.schemaResolver.schemaToType(bodyOrRef, imports, 0); }
const body = bodyOrRef;
const contentTypes = Object.entries(body.content || {});
if (contentTypes.length === 0) return 'unknown';
const parts: string[] = [];
for (const [contentType, mediaType] of contentTypes) {
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, imports, 0);
parts.push(`content: { ${quotePropertyName(contentType)}: ${type} }`);
}
}
if (parts.length === 0) return 'unknown';
return `{ ${parts.join('; ')} }`;
}
private generateResponsesInline( responses: Record<string, ResponseObject | ReferenceObject>, imports: ImportInfo[] ): string { const parts: string[] = [];
for (const [statusCode, responseOrRef] of Object.entries(responses)) {
if (isReferenceObject(responseOrRef)) {
const type = this.schemaResolver.schemaToType(responseOrRef, imports, 0);
parts.push(`${quotePropertyName(statusCode)}: ${type}`);
continue;
}
const response = responseOrRef;
const contentTypes = Object.entries(response.content || {});
if (contentTypes.length === 0) {
parts.push(`${quotePropertyName(statusCode)}: { description: '${(response.description || '').replace(/'/g, "\\'")}' }`);
continue;
}
const contentParts: string[] = [];
for (const [contentType, mediaType] of contentTypes) {
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, imports, 0);
contentParts.push(`${quotePropertyName(contentType)}: ${type}`);
}
}
parts.push(`${quotePropertyName(statusCode)}: { content: { ${contentParts.join('; ')} } }`);
}
if (parts.length === 0) return '{}';
return `{ ${parts.join('; ')} }`;
}
private generatePathParamTypes(document: OpenAPIDocument, imports: ImportInfo[]): string { const paths = document.paths || {}; const lines: string[] = [];
lines.push('/** Path parameter types for each endpoint */');
lines.push('export interface PathParams {');
for (const [pathStr, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
// Extract path parameters like {petId}
const pathParams = pathStr.match(/\{([^}]+)\}/g);
if (!pathParams) continue;
const paramEntries: string[] = [];
for (const param of pathParams) {
const paramName = param.slice(1, -1);
// Try to find type from path-level or operation-level parameters
const paramType = this.findPathParamType(pathItem, paramName, imports);
paramEntries.push(`${quotePropertyName(paramName)}: ${paramType}`);
}
lines.push(` ${quotePropertyName(pathStr)}: { ${paramEntries.join('; ')} };`);
}
lines.push('}');
return lines.join('\n');
}
private findPathParamType( pathItem: PathItemObject, paramName: string, imports: ImportInfo[] ): string { // Check path-level parameters const params = pathItem.parameters || []; for (const paramOrRef of params) { if (isReferenceObject(paramOrRef)) continue; if (paramOrRef.in === 'path' && paramOrRef.name === paramName) { return paramOrRef.schema ? this.schemaResolver.schemaToType(paramOrRef.schema, imports, 0) : 'string'; } }
// Check each operation's parameters
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!operation?.parameters) continue;
for (const paramOrRef of operation.parameters) {
if (isReferenceObject(paramOrRef)) continue;
if (paramOrRef.in === 'path' && paramOrRef.name === paramName) {
return paramOrRef.schema
? this.schemaResolver.schemaToType(paramOrRef.schema, imports, 0)
: 'string';
}
}
}
return 'string'; // default
}
private buildCategoryFileMap(): Map<string, string> { const naming = this.config.naming || {}; const map = new Map<string, string>(); map.set('schemas', applyFileNaming('schemas', naming)); map.set('responses', applyFileNaming('responses', naming)); map.set('parameters', applyFileNaming('parameters', naming)); map.set('requestBodies', applyFileNaming('request-bodies', naming)); map.set('headers', applyFileNaming('headers', naming)); return map; } } TypeScript
// src/generator/operations-generator.ts
import { OperationObject, ParameterObject, ReferenceObject, RequestBodyObject, ResponseObject, isReferenceObject, } from '../types/openapi'; import { ParsedOperation } from '../parser/openapi-parser'; import { TypeGenerator } from './type-generator'; import { SchemaResolver, ImportInfo } from '../parser/schema-resolver'; import { RefResolver } from '../parser/ref-resolver'; import { GeneratorConfig } from '../types/config'; import { GeneratedFile, buildImportStatements, buildJSDoc, deduplicateImports, } from './helpers'; import { applyFileNaming, applyTypeNaming, operationIdToTypeName, quotePropertyName, httpStatusToName, } from '../utils/naming';
export class OperationsGenerator { private typeGenerator: TypeGenerator; private schemaResolver: SchemaResolver; private refResolver: RefResolver; private config: GeneratorConfig;
constructor(typeGenerator: TypeGenerator, refResolver: RefResolver, config: GeneratorConfig) { this.typeGenerator = typeGenerator; this.refResolver = refResolver; this.config = config; this.schemaResolver = typeGenerator.getSchemaResolver(); }
generate(operations: ParsedOperation[]): GeneratedFile[] { if (this.config.split?.groupByTag) { return this.generateByTag(operations); }
return [this.generateSingleFile(operations)];
}
private generateSingleFile(operations: ParsedOperation[]): GeneratedFile { const naming = this.config.naming || {}; const allImports: ImportInfo[] = []; const lines: string[] = [];
for (const op of operations) {
const opTypes = this.generateOperationType(op, allImports);
lines.push(opTypes);
lines.push('');
}
// Build master operations map
lines.push('/** Map of all operations by operationId */');
lines.push('export interface Operations {');
for (const op of operations) {
const typeName = this.getOperationTypeName(op);
lines.push(` ${quotePropertyName(op.operationId)}: ${typeName};`);
}
lines.push('}');
const dedupedImports = deduplicateImports(allImports);
const categoryFileMap = this.buildCategoryFileMap();
const importStatements = buildImportStatements(
dedupedImports,
'operations',
this.config,
categoryFileMap
);
let content = '';
if (importStatements) {
content += importStatements + '\n\n';
}
content += lines.join('\n');
const fileName = applyFileNaming('operations', naming);
return {
fileName,
content,
category: 'operations',
};
}
private generateByTag(operations: ParsedOperation[]): GeneratedFile[] { const tagGroups = new Map<string, ParsedOperation[]>();
for (const op of operations) {
for (const tag of op.tags) {
if (!tagGroups.has(tag)) {
tagGroups.set(tag, []);
}
tagGroups.get(tag)!.push(op);
}
}
const files: GeneratedFile[] = [];
const naming = this.config.naming || {};
for (const [tag, ops] of tagGroups) {
const allImports: ImportInfo[] = [];
const lines: string[] = [];
for (const op of ops) {
const opTypes = this.generateOperationType(op, allImports);
lines.push(opTypes);
lines.push('');
}
const dedupedImports = deduplicateImports(allImports);
const categoryFileMap = this.buildCategoryFileMap();
const importStatements = buildImportStatements(
dedupedImports,
'operations',
this.config,
categoryFileMap
);
let content = '';
if (importStatements) {
content += importStatements + '\n\n';
}
content += lines.join('\n');
const fileName = `operations/${applyFileNaming(tag, naming)}`;
files.push({
fileName,
content,
category: 'operations',
});
}
return files;
}
private generateOperationType(op: ParsedOperation, imports: ImportInfo[]): string { const lines: string[] = []; const typeName = this.getOperationTypeName(op); const suffix = this.config.naming?.operationSuffix || '';
// JSDoc
if (this.config.jsdoc) {
const jsdoc = buildJSDoc(
op.operation.description || op.operation.summary,
op.operation.deprecated,
{
method: op.method.toUpperCase(),
path: op.path,
}
);
if (jsdoc) lines.push(jsdoc);
}
// Parameter types
const paramTypes = this.generateOperationParams(op.operation, imports);
if (paramTypes.query) {
const queryName = `${typeName}QueryParams`;
lines.push(`export type ${queryName} = ${paramTypes.query};`);
lines.push('');
}
if (paramTypes.path) {
const pathName = `${typeName}PathParams`;
lines.push(`export type ${pathName} = ${paramTypes.path};`);
lines.push('');
}
if (paramTypes.header) {
const headerName = `${typeName}HeaderParams`;
lines.push(`export type ${headerName} = ${paramTypes.header};`);
lines.push('');
}
// Request body type
if (op.operation.requestBody) {
const bodyType = this.generateRequestBodyType(op.operation.requestBody, imports);
const bodyName = `${typeName}RequestBody`;
lines.push(`export type ${bodyName} = ${bodyType};`);
lines.push('');
}
// Response types
if (op.operation.responses) {
const responseTypes = this.generateResponseTypes(typeName, op.operation.responses, imports);
lines.push(responseTypes);
}
// Main operation interface
lines.push(`export interface ${typeName}${suffix} {`);
if (paramTypes.query) lines.push(` parameters: {`);
if (paramTypes.query) lines.push(` query: ${typeName}QueryParams;`);
if (paramTypes.path) {
if (!paramTypes.query) lines.push(` parameters: {`);
lines.push(` path: ${typeName}PathParams;`);
}
if (paramTypes.header) {
if (!paramTypes.query && !paramTypes.path) lines.push(` parameters: {`);
lines.push(` header: ${typeName}HeaderParams;`);
}
if (paramTypes.query || paramTypes.path || paramTypes.header) {
lines.push(` };`);
}
if (op.operation.requestBody) {
lines.push(` requestBody: ${typeName}RequestBody;`);
}
if (op.operation.responses) {
lines.push(` responses: ${typeName}Responses;`);
}
lines.push('}');
return lines.join('\n');
}
private generateOperationParams( operation: OperationObject, imports: ImportInfo[] ): { query?: string; path?: string; header?: string; cookie?: string } { const result: Record<string, string | undefined> = {};
if (!operation.parameters) return result;
const grouped: Record<string, string[]> = {
query: [],
path: [],
header: [],
cookie: [],
};
for (const paramOrRef of operation.parameters) {
if (isReferenceObject(paramOrRef)) {
const parsed = this.refResolver.parseRef(paramOrRef.$ref);
imports.push({
typeName: parsed.name,
ref: paramOrRef.$ref,
category: parsed.category,
});
continue;
}
const param = paramOrRef;
const paramType = param.schema
? this.schemaResolver.schemaToType(param.schema, imports, 0)
: 'unknown';
const optional = param.required ? '' : '?';
grouped[param.in]?.push(`${quotePropertyName(param.name)}${optional}: ${paramType}`);
}
for (const [location, params] of Object.entries(grouped)) {
if (params.length > 0) {
result[location] = `{\n ${params.join(';\n ')};\n}`;
}
}
return result;
}
private generateRequestBodyType( bodyOrRef: RequestBodyObject | ReferenceObject, imports: ImportInfo[] ): string { if (isReferenceObject(bodyOrRef)) { return this.schemaResolver.schemaToType(bodyOrRef, imports, 0); }
const body = bodyOrRef;
const contentEntries = Object.entries(body.content || {});
if (contentEntries.length === 0) return 'unknown';
// If there's only one content type with application/json, simplify
if (contentEntries.length === 1) {
const [, mediaType] = contentEntries[0];
if (mediaType.schema) {
return this.schemaResolver.schemaToType(mediaType.schema, imports, 0);
}
}
// Multiple content types
const parts: string[] = [];
for (const [contentType, mediaType] of contentEntries) {
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, imports, 0);
parts.push(`${quotePropertyName(contentType)}: ${type}`);
}
}
return `{ ${parts.join('; ')} }`;
}
private generateResponseTypes( operationTypeName: string, responses: Record<string, ResponseObject | ReferenceObject>, imports: ImportInfo[] ): string { const lines: string[] = [];
// Individual response types
for (const [statusCode, responseOrRef] of Object.entries(responses)) {
const statusName = httpStatusToName(statusCode);
const responseTypeName = `${operationTypeName}Response${statusName}`;
if (isReferenceObject(responseOrRef)) {
const type = this.schemaResolver.schemaToType(responseOrRef, imports, 0);
lines.push(`export type ${responseTypeName} = ${type};`);
continue;
}
const response = responseOrRef;
const contentEntries = Object.entries(response.content || {});
if (contentEntries.length === 0) {
lines.push(`export type ${responseTypeName} = void;`);
continue;
}
if (contentEntries.length === 1) {
const [, mediaType] = contentEntries[0];
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, imports, 0);
lines.push(`export type ${responseTypeName} = ${type};`);
continue;
}
}
const contentParts: string[] = [];
for (const [contentType, mediaType] of contentEntries) {
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, imports, 0);
contentParts.push(`${quotePropertyName(contentType)}: ${type}`);
}
}
lines.push(`export type ${responseTypeName} = { ${contentParts.join('; ')} };`);
}
lines.push('');
// Combined responses type
lines.push(`export interface ${operationTypeName}Responses {`);
for (const [statusCode] of Object.entries(responses)) {
const statusName = httpStatusToName(statusCode);
lines.push(
` ${quotePropertyName(statusCode)}: ${operationTypeName}Response${statusName};`
);
}
lines.push('}');
lines.push('');
return lines.join('\n');
}
private getOperationTypeName(op: ParsedOperation): string { return applyTypeNaming(operationIdToTypeName(op.operationId), this.config.naming || {}); }
private buildCategoryFileMap(): Map<string, string> { const naming = this.config.naming || {}; const map = new Map<string, string>(); map.set('schemas', applyFileNaming('schemas', naming)); map.set('responses', applyFileNaming('responses', naming)); map.set('parameters', applyFileNaming('parameters', naming)); map.set('requestBodies', applyFileNaming('request-bodies', naming)); map.set('headers', applyFileNaming('headers', naming)); return map; } } TypeScript
// src/generator/responses-generator.ts
import { OpenAPIDocument, ResponseObject, ReferenceObject, isReferenceObject, } from '../types/openapi'; import { TypeGenerator } from './type-generator'; import { SchemaResolver, ImportInfo } from '../parser/schema-resolver'; import { RefResolver } from '../parser/ref-resolver'; import { GeneratorConfig } from '../types/config'; import { GeneratedFile, buildImportStatements, buildJSDoc, deduplicateImports, } from './helpers'; import { applyFileNaming, applyTypeNaming, quotePropertyName } from '../utils/naming';
export class ResponsesGenerator { private schemaResolver: SchemaResolver; private refResolver: RefResolver; private config: GeneratorConfig;
constructor(typeGenerator: TypeGenerator, refResolver: RefResolver, config: GeneratorConfig) { this.refResolver = refResolver; this.config = config; this.schemaResolver = typeGenerator.getSchemaResolver(); }
generate(document: OpenAPIDocument): GeneratedFile[] { const responses = document.components?.responses; if (!responses) return [];
const naming = this.config.naming || {};
const allImports: ImportInfo[] = [];
const lines: string[] = [];
const suffix = this.config.naming?.responseSuffix || '';
for (const [name, responseOrRef] of Object.entries(responses)) {
const typeName = applyTypeNaming(name, naming) + suffix;
if (isReferenceObject(responseOrRef)) {
const refType = this.schemaResolver.schemaToType(responseOrRef, allImports, 0);
lines.push(`export type ${typeName} = ${refType};`);
lines.push('');
continue;
}
const response = responseOrRef;
if (this.config.jsdoc && response.description) {
lines.push(buildJSDoc(response.description));
}
const contentEntries = Object.entries(response.content || {});
if (contentEntries.length === 0) {
lines.push(`export type ${typeName} = void;`);
} else if (contentEntries.length === 1) {
const [, mediaType] = contentEntries[0];
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, allImports, 0);
lines.push(`export type ${typeName} = ${type};`);
} else {
lines.push(`export type ${typeName} = unknown;`);
}
} else {
lines.push(`export type ${typeName} = {`);
for (const [contentType, mediaType] of contentEntries) {
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, allImports, 0);
lines.push(` ${quotePropertyName(contentType)}: ${type};`);
}
}
lines.push('};');
}
// Generate header types if present
if (response.headers) {
lines.push('');
lines.push(`export interface ${typeName}Headers {`);
for (const [headerName, headerOrRef] of Object.entries(response.headers)) {
if (isReferenceObject(headerOrRef)) {
const type = this.schemaResolver.schemaToType(headerOrRef, allImports, 0);
lines.push(` ${quotePropertyName(headerName)}: ${type};`);
} else if (headerOrRef.schema) {
const type = this.schemaResolver.schemaToType(headerOrRef.schema, allImports, 0);
const optional = headerOrRef.required ? '' : '?';
lines.push(` ${quotePropertyName(headerName)}${optional}: ${type};`);
}
}
lines.push('}');
}
lines.push('');
}
const dedupedImports = deduplicateImports(allImports);
const categoryFileMap = this.buildCategoryFileMap();
const importStatements = buildImportStatements(
dedupedImports,
'responses',
this.config,
categoryFileMap
);
let content = '';
if (importStatements) {
content += importStatements + '\n\n';
}
content += lines.join('\n');
const fileName = applyFileNaming('responses', naming);
return [{ fileName, content, category: 'responses' }];
}
private buildCategoryFileMap(): Map<string, string> { const naming = this.config.naming || {}; const map = new Map<string, string>(); map.set('schemas', applyFileNaming('schemas', naming)); map.set('parameters', applyFileNaming('parameters', naming)); map.set('requestBodies', applyFileNaming('request-bodies', naming)); map.set('headers', applyFileNaming('headers', naming)); return map; } } TypeScript
// src/generator/request-bodies-generator.ts
import { OpenAPIDocument, RequestBodyObject, ReferenceObject, isReferenceObject, } from '../types/openapi'; import { TypeGenerator } from './type-generator'; import { SchemaResolver, ImportInfo } from '../parser/schema-resolver'; import { RefResolver } from '../parser/ref-resolver'; import { GeneratorConfig } from '../types/config'; import { GeneratedFile, buildImportStatements, buildJSDoc, deduplicateImports, } from './helpers'; import { applyFileNaming, applyTypeNaming, quotePropertyName } from '../utils/naming';
export class RequestBodiesGenerator { private schemaResolver: SchemaResolver; private refResolver: RefResolver; private config: GeneratorConfig;
constructor(typeGenerator: TypeGenerator, refResolver: RefResolver, config: GeneratorConfig) { this.refResolver = refResolver; this.config = config; this.schemaResolver = typeGenerator.getSchemaResolver(); }
generate(document: OpenAPIDocument): GeneratedFile[] { const requestBodies = document.components?.requestBodies; if (!requestBodies) return [];
const naming = this.config.naming || {};
const allImports: ImportInfo[] = [];
const lines: string[] = [];
const suffix = this.config.naming?.requestBodySuffix || '';
for (const [name, bodyOrRef] of Object.entries(requestBodies)) {
const typeName = applyTypeNaming(name, naming) + suffix;
if (isReferenceObject(bodyOrRef)) {
const refType = this.schemaResolver.schemaToType(bodyOrRef, allImports, 0);
lines.push(`export type ${typeName} = ${refType};`);
lines.push('');
continue;
}
const body = bodyOrRef;
if (this.config.jsdoc && body.description) {
lines.push(buildJSDoc(body.description));
}
const contentEntries = Object.entries(body.content || {});
if (contentEntries.length === 0) {
lines.push(`export type ${typeName} = unknown;`);
} else if (contentEntries.length === 1) {
const [, mediaType] = contentEntries[0];
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, allImports, 0);
lines.push(`export type ${typeName} = ${type};`);
} else {
lines.push(`export type ${typeName} = unknown;`);
}
} else {
lines.push(`export type ${typeName} = {`);
for (const [contentType, mediaType] of contentEntries) {
if (mediaType.schema) {
const type = this.schemaResolver.schemaToType(mediaType.schema, allImports, 0);
lines.push(` ${quotePropertyName(contentType)}: ${type};`);
}
}
lines.push('};');
}
lines.push('');
}
const dedupedImports = deduplicateImports(allImports);
const categoryFileMap = this.buildCategoryFileMap();
const importStatements = buildImportStatements(
dedupedImports,
'requestBodies',
this.config,
categoryFileMap
);
let content = '';
if (importStatements) {
content += importStatements + '\n\n';
}
content += lines.join('\n');
const fileName = applyFileNaming('request-bodies', naming);
return [{ fileName, content, category: 'requestBodies' }];
}
private buildCategoryFileMap(): Map<string, string> { const naming = this.config.naming || {}; const map = new Map<string, string>(); map.set('schemas', applyFileNaming('schemas', naming)); map.set('responses', applyFileNaming('responses', naming)); map.set('parameters', applyFileNaming('parameters', naming)); map.set('headers', applyFileNaming('headers', naming)); return map; } } TypeScript
// src/generator/parameters-generator.ts
import { OpenAPIDocument, ParameterObject, ReferenceObject, isReferenceObject, } from '../types/openapi'; import { TypeGenerator } from './type-generator'; import { SchemaResolver, ImportInfo } from '../parser/schema-resolver'; import { RefResolver } from '../parser/ref-resolver'; import { GeneratorConfig } from '../types/config'; import { GeneratedFile, buildImportStatements, buildJSDoc, deduplicateImports, } from './helpers'; import { applyFileNaming, applyTypeNaming, quotePropertyName } from '../utils/naming';
export class ParametersGenerator { private schemaResolver: SchemaResolver; private refResolver: RefResolver; private config: GeneratorConfig;
constructor(typeGenerator: TypeGenerator, refResolver: RefResolver, config: GeneratorConfig) { this.refResolver = refResolver; this.config = config; this.schemaResolver = typeGenerator.getSchemaResolver(); }
generate(document: OpenAPIDocument): GeneratedFile[] { const parameters = document.components?.parameters; if (!parameters) return [];
const naming = this.config.naming || {};
const allImports: ImportInfo[] = [];
const lines: string[] = [];
const suffix = this.config.naming?.parameterSuffix || '';
for (const [name, paramOrRef] of Object.entries(parameters)) {
const typeName = applyTypeNaming(name, naming) + suffix;
if (isReferenceObject(paramOrRef)) {
const refType = this.schemaResolver.schemaToType(paramOrRef, allImports, 0);
lines.push(`export type ${typeName} = ${refType};`);
lines.push('');
continue;
}
const param = paramOrRef;
if (this.config.jsdoc && param.description) {
lines.push(
buildJSDoc(param.description, param.deprecated, {
in: param.in,
name: param.name,
})
);
}
const paramType = param.schema
? this.schemaResolver.schemaToType(param.schema, allImports, 0)
: 'unknown';
lines.push(`export type ${typeName} = ${paramType};`);
lines.push('');
}
const dedupedImports = deduplicateImports(allImports);
const categoryFileMap = this.buildCategoryFileMap();
const importStatements = buildImportStatements(
dedupedImports,
'parameters',
this.config,
categoryFileMap
);
let content = '';
if (importStatements) {
content += importStatements + '\n\n';
}
content += lines.join('\n');
const fileName = applyFileNaming('parameters', naming);
return [{ fileName, content, category: 'parameters' }];
}
private buildCategoryFileMap(): Map<string, string> { const naming = this.config.naming || {}; const map = new Map<string, string>(); map.set('schemas', applyFileNaming('schemas', naming)); map.set('responses', applyFileNaming('responses', naming)); map.set('requestBodies', applyFileNaming('request-bodies', naming)); map.set('headers', applyFileNaming('headers', naming)); return map; } } TypeScript
// src/generator/index.ts
import { OpenAPIDocument } from '../types/openapi'; import { GeneratorConfig } from '../types/config'; import { ParsedDocument } from '../parser/openapi-parser'; import { TypeGenerator } from './type-generator'; import { ComponentsGenerator } from './components-generator'; import { PathsGenerator } from './paths-generator'; import { OperationsGenerator } from './operations-generator'; import { ResponsesGenerator } from './responses-generator'; import { RequestBodiesGenerator } from './request-bodies-generator'; import { ParametersGenerator } from './parameters-generator'; import { GeneratedFile } from './helpers'; import { Logger } from '../utils/logger';
export class Generator { private config: GeneratorConfig; private logger: Logger;
constructor(config: GeneratorConfig, logger: Logger) { this.config = config; this.logger = logger; }
generate(parsed: ParsedDocument): GeneratedFile[] { const { document, refResolver, operations } = parsed; const typeGenerator = new TypeGenerator(refResolver, this.config); const files: GeneratedFile[] = []; const split = this.config.split || {};
this.logger.group('Generating types');
// Schemas
if (split.schemas !== false) {
this.logger.debug('Generating schema types...');
const componentsGen = new ComponentsGenerator(typeGenerator, refResolver, this.config);
const schemaFiles = componentsGen.generateSchemas(document);
files.push(...schemaFiles);
this.logger.debug(` → ${schemaFiles.length} schema file(s)`);
}
// Paths
if (split.paths !== false) {
this.logger.debug('Generating path types...');
const pathsGen = new PathsGenerator(typeGenerator, refResolver, this.config);
const pathFiles = pathsGen.generate(document);
files.push(...pathFiles);
this.logger.debug(` → ${pathFiles.length} path file(s)`);
}
// Operations
if (split.operations !== false) {
this.logger.debug('Generating operation types...');
const opsGen = new OperationsGenerator(typeGenerator, refResolver, this.config);
const opsFiles = opsGen.generate(operations);
files.push(...opsFiles);
this.logger.debug(` → ${opsFiles.length} operation file(s)`);
}
// Responses
if (split.responses !== false) {
this.logger.debug('Generating response types...');
const responsesGen = new ResponsesGenerator(typeGenerator, refResolver, this.config);
const responseFiles = responsesGen.generate(document);
files.push(...responseFiles);
this.logger.debug(` → ${responseFiles.length} response file(s)`);
}
// Request Bodies
if (split.requestBodies !== false) {
this.logger.debug('Generating request body types...');
const reqBodiesGen = new RequestBodiesGenerator(typeGenerator, refResolver, this.config);
const reqBodyFiles = reqBodiesGen.generate(document);
files.push(...reqBodyFiles);
this.logger.debug(` → ${reqBodyFiles.length} request body file(s)`);
}
// Parameters
if (split.parameters !== false) {
this.logger.debug('Generating parameter types...');
const paramsGen = new ParametersGenerator(typeGenerator, refResolver, this.config);
const paramFiles = paramsGen.generate(document);
files.push(...paramFiles);
this.logger.debug(` → ${paramFiles.length} parameter file(s)`);
}
this.logger.success(`Generated ${files.length} file(s) total`);
return files;
} }
export { GeneratedFile } from './helpers'; export { TypeGenerator } from './type-generator'; export { ComponentsGenerator } from './components-generator'; export { PathsGenerator } from './paths-generator'; export { OperationsGenerator } from './operations-generator'; export { ResponsesGenerator } from './responses-generator'; export { RequestBodiesGenerator } from './request-bodies-generator'; export { ParametersGenerator } from './parameters-generator'; 6. File Emitter TypeScript
// src/emitter/formatter.ts
import * as prettier from 'prettier'; import { FormatOptions } from '../types/config';
export class Formatter { private options: FormatOptions;
constructor(options: FormatOptions = {}) { this.options = options; }
async format(content: string): Promise { if (this.options.prettier === false) { return content; }
try {
const prettierOptions: prettier.Options = {
parser: 'typescript',
printWidth: this.options.printWidth || 100,
singleQuote: this.options.singleQuote !== false,
trailingComma: (this.options.trailingComma as prettier.Options['trailingComma']) || 'all',
semi: this.options.semi !== false,
tabWidth: this.options.indent || 2,
};
return await prettier.format(content, prettierOptions);
} catch (error) {
// If prettier fails, return unformatted content
return content;
}
} } TypeScript
// src/emitter/file-emitter.ts
import * as path from 'path'; import { GeneratedFile } from '../generator/helpers'; import { GeneratorConfig } from '../types/config'; import { Formatter } from './formatter'; import { Logger } from '../utils/logger'; import { ensureDir, writeFile } from '../utils/fs'; import { applyFileNaming } from '../utils/naming';
export class FileEmitter { private config: GeneratorConfig; private formatter: Formatter; private logger: Logger;
constructor(config: GeneratorConfig, logger: Logger) { this.config = config; this.formatter = new Formatter(config.format); this.logger = logger; }
async emit(files: GeneratedFile[]): Promise<string[]> { const outputDir = path.resolve(this.config.output); ensureDir(outputDir);
this.logger.group('Writing files');
const writtenFiles: string[] = [];
for (const file of files) {
const filePath = path.join(outputDir, `${file.fileName}.ts`);
// Prepend header
let content = '';
if (this.config.header) {
content += this.config.header + '\n\n';
}
content += file.content;
// Format
content = await this.formatter.format(content);
// Write
writeFile(filePath, content);
writtenFiles.push(filePath);
this.logger.file(path.relative(process.cwd(), filePath));
}
// Generate barrel exports
if (this.config.barrelExports) {
const indexPath = await this.generateBarrelExport(files, outputDir);
if (indexPath) {
writtenFiles.push(indexPath);
}
}
this.logger.success(`Wrote ${writtenFiles.length} file(s) to ${path.relative(process.cwd(), outputDir)}/`);
return writtenFiles;
}
private async generateBarrelExport( files: GeneratedFile[], outputDir: string ): Promise<string | null> { if (files.length === 0) return null;
const lines: string[] = [];
if (this.config.header) {
lines.push(this.config.header);
lines.push('');
}
// Sort for consistent output
const sortedFiles = [...files].sort((a, b) => a.fileName.localeCompare(b.fileName));
for (const file of sortedFiles) {
const importPath = `./${file.fileName}`;
lines.push(`export * from '${importPath}';`);
}
lines.push('');
let content = lines.join('\n');
content = await this.formatter.format(content);
const indexPath = path.join(outputDir, 'index.ts');
writeFile(indexPath, content);
this.logger.file(path.relative(process.cwd(), indexPath));
return indexPath;
} } TypeScript
// src/emitter/index.ts
export { FileEmitter } from './file-emitter'; export { Formatter } from './formatter'; 7. Main Entry Point TypeScript
// src/index.ts
import { OpenAPIParser } from './parser'; import { Generator, GeneratedFile } from './generator'; import { FileEmitter } from './emitter'; import { GeneratorConfig, DEFAULT_CONFIG } from './types/config'; import { Logger } from './utils/logger';
export async function generate(userConfig: Partial & { input: string }): Promise<string[]> { const config = mergeConfig(userConfig); const logger = new Logger(config.verbose);
try { // Parse const parser = new OpenAPIParser(logger); const parsed = await parser.parse(config.input);
// Generate
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
// Emit
const emitter = new FileEmitter(config, logger);
const writtenFiles = await emitter.emit(files);
return writtenFiles;
} catch (error) {
logger.error(Generation failed: ${(error as Error).message});
throw error;
}
}
/**
- Generate types but return as in-memory strings (no file writing) */ export async function generateToMemory( userConfig: Partial & { input: string } ): Promise<GeneratedFile[]> { const config = mergeConfig(userConfig); const logger = new Logger(config.verbose);
const parser = new OpenAPIParser(logger); const parsed = await parser.parse(config.input);
const generator = new Generator(config, logger); return generator.generate(parsed); }
function mergeConfig(userConfig: Partial): GeneratorConfig { return { ...DEFAULT_CONFIG, ...userConfig, split: { ...DEFAULT_CONFIG.split, ...(userConfig.split || {}), }, format: { ...DEFAULT_CONFIG.format, ...(userConfig.format || {}), }, naming: { ...DEFAULT_CONFIG.naming, ...(userConfig.naming || {}), }, } as GeneratorConfig; }
// Re-export types for consumers export { GeneratorConfig, SplitOptions, FormatOptions, NamingOptions } from './types/config'; export { GeneratedFile } from './generator'; export { OpenAPIParser, ParsedDocument, ParsedOperation } from './parser'; export { Generator } from './generator'; export { FileEmitter } from './emitter'; export { Formatter } from './emitter/formatter'; 8. CLI TypeScript
// bin/cli.ts #!/usr/bin/env node
import { Command } from 'commander'; import * as path from 'path'; import * as fs from 'fs'; import { generate } from '../src/index'; import { GeneratorConfig } from '../src/types/config';
const pkg = JSON.parse( fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8') );
const program = new Command();
program .name('openapi-split-typegen') .description('Generate split TypeScript types from OpenAPI 3.1 specifications') .version(pkg.version);
program .argument('', 'Path to OpenAPI spec file (JSON or YAML)') .option('-o, --output
', 'Output directory', './generated') .option('--no-schemas', 'Skip schema generation') .option('--no-paths', 'Skip path generation') .option('--no-operations', 'Skip operation generation') .option('--no-parameters', 'Skip parameter generation') .option('--no-responses', 'Skip response generation') .option('--no-request-bodies', 'Skip request body generation') .option('--individual-schemas', 'Split each schema into its own file') .option('--group-by-tag', 'Group operations by tag into separate files') .option('--no-barrel', 'Skip barrel export (index.ts) generation') .option('--no-jsdoc', 'Skip JSDoc comment generation') .option('--no-prettier', 'Skip prettier formatting') .option('--immutable', 'Generate readonly types') .option('--export-type', 'Use "export type" instead of "export"', true) .option('--type-prefix ', 'Prefix for all type names') .option('--type-suffix ', 'Suffix for all type names') .option('--type-naming ', 'Type naming convention (PascalCase|camelCase|preserve)', 'PascalCase') .option('--file-naming ', 'File naming convention (kebab-case|camelCase|PascalCase|snake_case)', 'kebab-case') .option('--additional-properties ', 'How to handle additionalProperties (strict|permissive)', 'permissive') .option('--header ', 'Custom header for generated files') .option('--config ', 'Path to config file (JSON)') .option('-v, --verbose', 'Enable verbose logging') .action(async (input: string, options: Record<string, unknown>) => { try { let config: Partial = {}; // Load config file if specified
if (options.config) {
const configPath = path.resolve(options.config as string);
if (fs.existsSync(configPath)) {
const configContent = fs.readFileSync(configPath, 'utf-8');
config = JSON.parse(configContent);
} else {
console.error(`Config file not found: ${configPath}`);
process.exit(1);
}
}
// CLI options override config file
config.input = path.resolve(input);
config.output = options.output as string || config.output || './generated';
config.verbose = (options.verbose as boolean) || config.verbose;
config.immutable = (options.immutable as boolean) || config.immutable;
config.jsdoc = options.jsdoc !== false;
config.barrelExports = options.barrel !== false;
if (options.header) {
config.header = options.header as string;
}
config.split = {
...config.split,
schemas: options.schemas !== false,
paths: options.paths !== false,
operations: options.operations !== false,
parameters: options.parameters !== false,
responses: options.responses !== false,
requestBodies: options.requestBodies !== false,
individualSchemas: options.individualSchemas as boolean || false,
groupByTag: options.groupByTag as boolean || false,
};
config.format = {
...config.format,
prettier: options.prettier !== false,
};
config.naming = {
...config.naming,
typeNaming: (options.typeNaming as 'PascalCase' | 'camelCase' | 'preserve') || 'PascalCase',
fileNaming: (options.fileNaming as 'kebab-case') || 'kebab-case',
typePrefix: (options.typePrefix as string) || '',
typeSuffix: (options.typeSuffix as string) || '',
};
config.additionalProperties = (options.additionalProperties as 'strict' | 'permissive') || 'permissive';
await generate(config as GeneratorConfig);
console.log('\n✅ Done!');
} catch (error) {
console.error('\n❌ Error:', (error as Error).message);
if (options.verbose) {
console.error((error as Error).stack);
}
process.exit(1);
}
});
program.parse(); 9. Test Fixture & Tests JSON
// tests/fixtures/petstore.json { "openapi": "3.1.0", "info": { "title": "Petstore", "version": "1.0.0", "description": "A sample Pet Store API" }, "paths": { "/pets": { "get": { "operationId": "listPets", "summary": "List all pets", "tags": ["pets"], "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32" } }, { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "A list of pets", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Pet" } } } } }, "default": { "description": "unexpected error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } }, "post": { "operationId": "createPet", "summary": "Create a pet", "tags": ["pets"], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NewPet" } } } }, "responses": { "201": { "description": "Pet created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Pet" } } } } } } }, "/pets/{petId}": { "get": { "operationId": "getPetById", "summary": "Get a pet by ID", "tags": ["pets"], "parameters": [ { "name": "petId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "A pet", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Pet" } } } }, "404": { "description": "Pet not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } }, "delete": { "operationId": "deletePet", "summary": "Delete a pet", "tags": ["pets"], "parameters": [ { "name": "petId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Pet deleted" } } } }, "/users": { "get": { "operationId": "listUsers", "summary": "List users", "tags": ["users"], "responses": { "200": { "description": "User list", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } } } } } } }, "components": { "schemas": { "Pet": { "type": "object", "required": ["id", "name"], "properties": { "id": { "type": "integer", "format": "int64" }, "name": { "type": "string" }, "tag": { "type": "string" }, "status": { "type": "string", "enum": ["available", "pending", "sold"] }, "category": { "$ref": "#/components/schemas/Category" } } }, "NewPet": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" }, "tag": { "type": "string" }, "status": { "type": "string", "enum": ["available", "pending", "sold"] } } }, "Category": { "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } }, "User": { "type": "object", "required": ["id", "email"], "properties": { "id": { "type": "integer" }, "email": { "type": "string", "format": "email" }, "name": { "type": "string" }, "role": { "type": "string", "enum": ["admin", "user", "moderator"] } } }, "Error": { "type": "object", "required": ["code", "message"], "properties": { "code": { "type": "integer", "format": "int32" }, "message": { "type": "string" } } }, "PetOrUser": { "oneOf": [ { "$ref": "#/components/schemas/Pet" }, { "$ref": "#/components/schemas/User" } ] }, "NullableString": { "type": ["string", "null"] }, "PaginatedResponse": { "type": "object", "required": ["data", "total"], "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Pet" } }, "total": { "type": "integer" }, "page": { "type": "integer" }, "perPage": { "type": "integer" } } } }, "parameters": { "LimitParam": { "name": "limit", "in": "query", "description": "Maximum number of items to return", "required": false, "schema": { "type": "integer", "format": "int32", "minimum": 1, "maximum": 100 } }, "OffsetParam": { "name": "offset", "in": "query", "description": "Number of items to skip", "required": false, "schema": { "type": "integer", "minimum": 0 } } }, "responses": { "NotFound": { "description": "The requested resource was not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "Unauthorized": { "description": "Authentication required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "requestBodies": { "PetBody": { "description": "Pet data", "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NewPet" } } } } } } } TypeScript
// tests/parser.test.ts
import { describe, it, expect } from 'vitest'; import * as path from 'path'; import { OpenAPIParser } from '../src/parser/openapi-parser'; import { RefResolver } from '../src/parser/ref-resolver'; import { SchemaResolver } from '../src/parser/schema-resolver'; import { Logger } from '../src/utils/logger';
const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'petstore.json');
describe('OpenAPIParser', () => { const logger = new Logger(false);
it('should parse a valid OpenAPI 3.1 document', async () => { const parser = new OpenAPIParser(logger); const result = await parser.parse(FIXTURE_PATH);
expect(result.document.openapi).toBe('3.1.0');
expect(result.document.info.title).toBe('Petstore');
expect(result.operations.length).toBeGreaterThan(0);
});
it('should extract all operations', async () => { const parser = new OpenAPIParser(logger); const result = await parser.parse(FIXTURE_PATH);
const opIds = result.operations.map((op) => op.operationId);
expect(opIds).toContain('listPets');
expect(opIds).toContain('createPet');
expect(opIds).toContain('getPetById');
expect(opIds).toContain('deletePet');
expect(opIds).toContain('listUsers');
});
it('should extract tags from operations', async () => { const parser = new OpenAPIParser(logger); const result = await parser.parse(FIXTURE_PATH);
const listPets = result.operations.find((op) => op.operationId === 'listPets');
expect(listPets?.tags).toContain('pets');
const listUsers = result.operations.find((op) => op.operationId === 'listUsers');
expect(listUsers?.tags).toContain('users');
}); });
describe('RefResolver', () => { it('should resolve $ref pointers', async () => { const parser = new OpenAPIParser(new Logger(false)); const result = await parser.parse(FIXTURE_PATH); const resolver = result.refResolver;
const petSchema = resolver.resolve<{ type: string }>('#/components/schemas/Pet');
expect(petSchema.type).toBe('object');
});
it('should parse $ref into category and name', () => { const parser = new OpenAPIParser(new Logger(false)); const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' } }; const resolver = new RefResolver(doc as any);
const result = resolver.parseRef('#/components/schemas/Pet');
expect(result.category).toBe('schemas');
expect(result.name).toBe('Pet');
});
it('should collect all $refs in an object', async () => { const parser = new OpenAPIParser(new Logger(false)); const result = await parser.parse(FIXTURE_PATH); const resolver = result.refResolver;
const petSchema = resolver.resolve('#/components/schemas/Pet');
const refs = resolver.collectRefs(petSchema);
expect(refs.has('#/components/schemas/Category')).toBe(true);
}); });
describe('SchemaResolver', () => { let schemaResolver: SchemaResolver;
const setup = async () => { const parser = new OpenAPIParser(new Logger(false)); const result = await parser.parse(FIXTURE_PATH); const refResolver = result.refResolver; return new SchemaResolver(refResolver); };
it('should convert primitive types', async () => { schemaResolver = await setup();
expect(schemaResolver.schemaToType({ type: 'string' })).toBe('string');
expect(schemaResolver.schemaToType({ type: 'integer' })).toBe('number');
expect(schemaResolver.schemaToType({ type: 'number' })).toBe('number');
expect(schemaResolver.schemaToType({ type: 'boolean' })).toBe('boolean');
expect(schemaResolver.schemaToType({ type: 'null' })).toBe('null');
});
it('should handle enum types', async () => { schemaResolver = await setup();
const result = schemaResolver.schemaToType({
type: 'string',
enum: ['a', 'b', 'c'],
});
expect(result).toBe("'a' | 'b' | 'c'");
});
it('should handle array types', async () => { schemaResolver = await setup();
const result = schemaResolver.schemaToType({
type: 'array',
items: { type: 'string' },
});
expect(result).toBe('string[]');
});
it('should handle object types', async () => { schemaResolver = await setup();
const result = schemaResolver.schemaToType({
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
age: { type: 'integer' },
},
});
expect(result).toContain('name: string');
expect(result).toContain('age?: number');
});
it('should handle nullable types (3.1 multi-type)', async () => { schemaResolver = await setup();
const result = schemaResolver.schemaToType({
type: ['string', 'null'],
});
expect(result).toBe('string | null');
});
it('should handle $ref', async () => { schemaResolver = await setup(); const imports: any[] = [];
const result = schemaResolver.schemaToType(
{ $ref: '#/components/schemas/Pet' },
imports
);
expect(result).toBe('Pet');
expect(imports.length).toBe(1);
expect(imports[0].typeName).toBe('Pet');
});
it('should handle oneOf', async () => { schemaResolver = await setup(); const imports: any[] = [];
const result = schemaResolver.schemaToType(
{
oneOf: [
{ $ref: '#/components/schemas/Pet' },
{ $ref: '#/components/schemas/User' },
],
},
imports
);
expect(result).toBe('Pet | User');
});
it('should handle const', async () => { schemaResolver = await setup();
expect(schemaResolver.schemaToType({ const: 'hello' })).toBe("'hello'");
expect(schemaResolver.schemaToType({ const: 42 })).toBe('42');
expect(schemaResolver.schemaToType({ const: true })).toBe('true');
}); }); TypeScript
// tests/generator.test.ts
import { describe, it, expect } from 'vitest'; import * as path from 'path'; import { OpenAPIParser } from '../src/parser/openapi-parser'; import { Generator } from '../src/generator'; import { GeneratorConfig, DEFAULT_CONFIG } from '../src/types/config'; import { Logger } from '../src/utils/logger';
const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'petstore.json');
describe('Generator', () => { const logger = new Logger(false);
const createConfig = (overrides: Partial = {}): GeneratorConfig => ({ ...DEFAULT_CONFIG, input: FIXTURE_PATH, output: '/tmp/test-output', ...overrides, });
it('should generate schema files', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig();
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const schemaFile = files.find((f) => f.category === 'schemas');
expect(schemaFile).toBeDefined();
expect(schemaFile!.content).toContain('Pet');
expect(schemaFile!.content).toContain('NewPet');
expect(schemaFile!.content).toContain('Category');
expect(schemaFile!.content).toContain('User');
expect(schemaFile!.content).toContain('Error');
});
it('should generate path files', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig();
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const pathFile = files.find((f) => f.category === 'paths');
expect(pathFile).toBeDefined();
expect(pathFile!.content).toContain("'/pets'");
expect(pathFile!.content).toContain("'/pets/{petId}'");
});
it('should generate operation files', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig();
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const opsFile = files.find((f) => f.category === 'operations');
expect(opsFile).toBeDefined();
expect(opsFile!.content).toContain('ListPets');
expect(opsFile!.content).toContain('CreatePet');
expect(opsFile!.content).toContain('GetPetById');
});
it('should generate response files', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig();
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const responseFile = files.find((f) => f.category === 'responses');
expect(responseFile).toBeDefined();
expect(responseFile!.content).toContain('NotFound');
expect(responseFile!.content).toContain('Unauthorized');
});
it('should generate parameter files', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig();
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const paramFile = files.find((f) => f.category === 'parameters');
expect(paramFile).toBeDefined();
expect(paramFile!.content).toContain('LimitParam');
expect(paramFile!.content).toContain('OffsetParam');
});
it('should generate request body files', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig();
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const reqBodyFile = files.find((f) => f.category === 'requestBodies');
expect(reqBodyFile).toBeDefined();
expect(reqBodyFile!.content).toContain('PetBody');
});
it('should respect split options', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig({
split: {
schemas: true,
paths: false,
operations: false,
parameters: false,
responses: false,
requestBodies: false,
},
});
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
expect(files.find((f) => f.category === 'schemas')).toBeDefined();
expect(files.find((f) => f.category === 'paths')).toBeUndefined();
expect(files.find((f) => f.category === 'operations')).toBeUndefined();
});
it('should generate individual schema files', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig({
split: {
...DEFAULT_CONFIG.split,
individualSchemas: true,
paths: false,
operations: false,
parameters: false,
responses: false,
requestBodies: false,
},
});
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const schemaFiles = files.filter((f) => f.category === 'schemas');
expect(schemaFiles.length).toBeGreaterThan(1);
});
it('should group operations by tag', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig({
split: {
...DEFAULT_CONFIG.split,
groupByTag: true,
schemas: false,
paths: false,
parameters: false,
responses: false,
requestBodies: false,
},
});
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const opsFiles = files.filter((f) => f.category === 'operations');
expect(opsFiles.length).toBe(2); // "pets" and "users"
});
it('should apply type naming conventions', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig({
naming: {
...DEFAULT_CONFIG.naming,
typePrefix: 'Api',
typeSuffix: 'Type',
},
});
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const schemaFile = files.find((f) => f.category === 'schemas');
expect(schemaFile!.content).toContain('ApiPetType');
});
it('should include JSDoc comments when enabled', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig({ jsdoc: true });
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const opsFile = files.find((f) => f.category === 'operations');
expect(opsFile!.content).toContain('/**');
expect(opsFile!.content).toContain('List all pets');
});
it('should generate immutable types when enabled', async () => { const parser = new OpenAPIParser(logger); const parsed = await parser.parse(FIXTURE_PATH);
const config = createConfig({ immutable: true });
const generator = new Generator(config, logger);
const files = generator.generate(parsed);
const schemaFile = files.find((f) => f.category === 'schemas');
expect(schemaFile!.content).toContain('readonly');
}); }); TypeScript
// tests/e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import * as path from 'path'; import * as fs from 'fs'; import { generate } from '../src/index';
const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'petstore.json'); const OUTPUT_DIR = path.join(__dirname, '..', 'tmp-test-output');
describe('E2E Generation', () => { afterAll(() => { // Cleanup if (fs.existsSync(OUTPUT_DIR)) { fs.rmSync(OUTPUT_DIR, { recursive: true }); } });
it('should generate all files end-to-end', async () => { const files = await generate({ input: FIXTURE_PATH, output: OUTPUT_DIR, verbose: false, format: { prettier: false }, // Skip prettier in tests for speed });
expect(files.length).toBeGreaterThan(0);
// Check that files exist
for (const file of files) {
expect(fs.existsSync(file)).toBe(true);
}
// Check index.ts exists
const indexPath = path.join(OUTPUT_DIR, 'index.ts');
expect(fs.existsSync(indexPath)).toBe(true);
// Verify schemas file content
const schemasPath = path.join(OUTPUT_DIR, 'schemas.ts');
if (fs.existsSync(schemasPath)) {
const content = fs.readFileSync(schemasPath, 'utf-8');
expect(content).toContain('export type');
expect(content).toContain('Pet');
}
});
it('should generate with individual schemas', async () => { const outDir = path.join(OUTPUT_DIR, 'individual');
const files = await generate({
input: FIXTURE_PATH,
output: outDir,
verbose: false,
format: { prettier: false },
split: {
individualSchemas: true,
paths: false,
operations: false,
parameters: false,
responses: false,
requestBodies: false,
},
});
expect(files.length).toBeGreaterThan(1);
}); }); 10. README Markdown
Generate split TypeScript types from OpenAPI 3.1 specifications.
Unlike openapi-typescript, this library generates separate files for schemas, paths, operations, parameters, responses, and request bodies — making large APIs easier to navigate and maintain.
- 🔀 Split generation — separate files for schemas, paths, operations, parameters, responses, request bodies
- 📁 Individual schema files — optionally one file per schema
- 🏷️ Group by tag — operations can be grouped into tag-based files
- 📝 JSDoc comments — auto-generated from OpenAPI descriptions
- 🔒 Immutable types — optional
readonlygeneration - 🎨 Configurable naming — PascalCase, camelCase, kebab-case, snake_case
- ✨ Prettier formatting — auto-formatted output
- 📦 Barrel exports — auto-generated
index.ts - 🔗 Proper imports — cross-file references resolved automatically
- 🛠️ CLI + API — use as command-line tool or programmatic API
npm install openapi-split-typegen
# or
yarn add openapi-split-typegen
# or
pnpm add openapi-split-typegen
Quick Start
CLI
Bash
# Basic usage
npx openapi-split-typegen ./openapi.json -o ./src/api/types
# With options
npx openapi-split-typegen ./openapi.yaml \
-o ./src/types \
--individual-schemas \
--group-by-tag \
--immutable \
--type-prefix Api
# Only generate schemas and operations
npx openapi-split-typegen ./openapi.json \
-o ./src/types \
--no-paths \
--no-parameters \
--no-responses \
--no-request-bodies
Programmatic API
TypeScript
import { generate } from 'openapi-split-typegen';
await generate({
input: './openapi.json',
output: './src/api/types',
split: {
schemas: true,
paths: true,
operations: true,
parameters: true,
responses: true,
requestBodies: true,
individualSchemas: false,
groupByTag: false,
},
naming: {
typeNaming: 'PascalCase',
fileNaming: 'kebab-case',
},
jsdoc: true,
immutable: false,
barrelExports: true,
});
In-memory generation (no file I/O)
TypeScript
import { generateToMemory } from