Skip to content

Instantly share code, notes, and snippets.

@celsowm
Created February 8, 2026 20:23
Show Gist options
  • Select an option

  • Save celsowm/fa59ab5fb405e00be75aff1d9daddd1f to your computer and use it in GitHub Desktop.

Select an option

Save celsowm/fa59ab5fb405e00be75aff1d9daddd1f to your computer and use it in GitHub Desktop.
openapi-split-typegen

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

  1. 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

openapi-split-typegen

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.

Features

  • 🔀 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 readonly generation
  • 🎨 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

Installation

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 
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment