UNPKG

@kubb/plugin-ts

Version:

TypeScript code generation plugin for Kubb, transforming OpenAPI schemas into TypeScript interfaces, types, and utility functions.

396 lines (346 loc) 15.9 kB
import transformers from '@kubb/core/transformers' import type { SchemaKeywordMapper, SchemaMapper } from '@kubb/plugin-oas' import { createParser, isKeyword, schemaKeywords } from '@kubb/plugin-oas' import type ts from 'typescript' import * as factory from './factory.ts' export const typeKeywordMapper = { any: () => factory.keywordTypeNodes.any, unknown: () => factory.keywordTypeNodes.unknown, void: () => factory.keywordTypeNodes.void, number: () => factory.keywordTypeNodes.number, integer: () => factory.keywordTypeNodes.number, object: (nodes?: ts.TypeElement[]) => { if (!nodes || !nodes.length) { return factory.keywordTypeNodes.object } return factory.createTypeLiteralNode(nodes) }, string: () => factory.keywordTypeNodes.string, boolean: () => factory.keywordTypeNodes.boolean, undefined: () => factory.keywordTypeNodes.undefined, nullable: undefined, null: () => factory.keywordTypeNodes.null, nullish: undefined, array: (nodes?: ts.TypeNode[], arrayType?: 'array' | 'generic') => { if (!nodes) { return undefined } return factory.createArrayDeclaration({ nodes, arrayType }) }, tuple: (nodes?: ts.TypeNode[], rest?: ts.TypeNode, min?: number, max?: number) => { if (!nodes) { return undefined } if (max) { nodes = nodes.slice(0, max) if (nodes.length < max && rest) { nodes = [...nodes, ...Array(max - nodes.length).fill(rest)] } } if (min) { nodes = nodes.map((node, index) => (index >= min ? factory.createOptionalTypeNode(node) : node)) } if (typeof max === 'undefined' && rest) { nodes.push(factory.createRestTypeNode(factory.createArrayTypeNode(rest))) } return factory.createTupleTypeNode(nodes) }, enum: (name?: string) => { if (!name) { return undefined } return factory.createTypeReferenceNode(name, undefined) }, union: (nodes?: ts.TypeNode[]) => { if (!nodes) { return undefined } return factory.createUnionDeclaration({ withParentheses: true, nodes, }) }, const: (name?: string | number | boolean, format?: 'string' | 'number' | 'boolean') => { if (name === null || name === undefined || name === '') { return undefined } if (format === 'boolean') { if (name === true) { return factory.createLiteralTypeNode(factory.createTrue()) } return factory.createLiteralTypeNode(factory.createFalse()) } if (format === 'number' && typeof name === 'number') { return factory.createLiteralTypeNode(factory.createNumericLiteral(name)) } return factory.createLiteralTypeNode(factory.createStringLiteral(name.toString())) }, datetime: () => factory.keywordTypeNodes.string, date: (type: 'date' | 'string' = 'string') => type === 'string' ? factory.keywordTypeNodes.string : factory.createTypeReferenceNode(factory.createIdentifier('Date')), time: (type: 'date' | 'string' = 'string') => type === 'string' ? factory.keywordTypeNodes.string : factory.createTypeReferenceNode(factory.createIdentifier('Date')), uuid: () => factory.keywordTypeNodes.string, url: () => factory.keywordTypeNodes.string, default: undefined, and: (nodes?: ts.TypeNode[]) => { if (!nodes) { return undefined } return factory.createIntersectionDeclaration({ withParentheses: true, nodes, }) }, describe: undefined, min: undefined, max: undefined, optional: undefined, matches: () => factory.keywordTypeNodes.string, email: () => factory.keywordTypeNodes.string, firstName: undefined, lastName: undefined, password: undefined, phone: undefined, readOnly: undefined, writeOnly: undefined, ref: (propertyName?: string) => { if (!propertyName) { return undefined } return factory.createTypeReferenceNode(propertyName, undefined) }, blob: () => factory.createTypeReferenceNode('Blob', []), deprecated: undefined, example: undefined, schema: undefined, catchall: undefined, name: undefined, interface: undefined, exclusiveMaximum: undefined, exclusiveMinimum: undefined, } satisfies SchemaMapper<ts.TypeNode | null | undefined> type ParserOptions = { /** * @default `'questionToken'` */ optionalType: 'questionToken' | 'undefined' | 'questionTokenAndUndefined' /** * @default `'array'` */ arrayType: 'array' | 'generic' /** * Choose to use `enum`, `asConst`, `asPascalConst`, `constEnum`, `literal`, or `inlineLiteral` for enums. * - `enum`: TypeScript enum * - `asConst`: const with camelCase name (e.g., `petType`) * - `asPascalConst`: const with PascalCase name (e.g., `PetType`) * - `constEnum`: const enum * - `literal`: literal union type * - `inlineLiteral`: inline enum values directly into the type (default in v5) * @default `'asConst'` * @note In Kubb v5, `inlineLiteral` will become the default. */ enumType: 'enum' | 'asConst' | 'asPascalConst' | 'constEnum' | 'literal' | 'inlineLiteral' mapper?: Record<string, ts.PropertySignature> } /** * Recursively parses a schema tree node into a corresponding TypeScript AST node. * * Maps OpenAPI schema keywords to TypeScript AST nodes using the `typeKeywordMapper`, handling complex types such as unions, intersections, arrays, tuples (with optional/rest elements and length constraints), enums, constants, references, and objects with property modifiers and documentation annotations. * * @param current - The schema node to parse. * @param siblings - Sibling schema nodes, used for context in certain mappings. * @param name - The name of the schema or property being parsed. * @param options - Parsing options controlling output style, property handling, and custom mappers. * @returns The generated TypeScript AST node, or `undefined` if the schema keyword is not mapped. */ export const parse = createParser<ts.Node | null, ParserOptions>({ mapper: typeKeywordMapper, handlers: { union(tree, options) { const { current, schema, name } = tree return typeKeywordMapper.union( current.args.map((it) => this.parse({ schema, parent: current, name, current: it, siblings: [] }, options)).filter(Boolean) as ts.TypeNode[], ) }, and(tree, options) { const { current, schema, name } = tree return typeKeywordMapper.and( current.args.map((it) => this.parse({ schema, parent: current, name, current: it, siblings: [] }, options)).filter(Boolean) as ts.TypeNode[], ) }, array(tree, options) { const { current, schema, name } = tree return typeKeywordMapper.array( current.args.items.map((it) => this.parse({ schema, parent: current, name, current: it, siblings: [] }, options)).filter(Boolean) as ts.TypeNode[], options.arrayType, ) }, enum(tree, options) { const { current } = tree // If enumType is 'inlineLiteral', generate the literal union inline instead of a type reference if (options.enumType === 'inlineLiteral') { const enumValues = current.args.items .map((item) => item.value) .filter((value): value is string | number | boolean => value !== undefined && value !== null) .map((value) => { const format = typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'boolean' : 'string' return typeKeywordMapper.const(value, format) }) .filter(Boolean) as ts.TypeNode[] return typeKeywordMapper.union(enumValues) } // Adding suffix to enum (see https://github.com/kubb-labs/kubb/issues/1873) return typeKeywordMapper.enum(options.enumType === 'asConst' ? `${current.args.typeName}Key` : current.args.typeName) }, ref(tree, _options) { const { current } = tree return typeKeywordMapper.ref(current.args.name) }, blob() { return typeKeywordMapper.blob() }, tuple(tree, options) { const { current, schema, name } = tree return typeKeywordMapper.tuple( current.args.items.map((it) => this.parse({ schema, parent: current, name, current: it, siblings: [] }, options)).filter(Boolean) as ts.TypeNode[], current.args.rest && ((this.parse({ schema, parent: current, name, current: current.args.rest, siblings: [] }, options) ?? undefined) as ts.TypeNode | undefined), current.args.min, current.args.max, ) }, const(tree, _options) { const { current } = tree return typeKeywordMapper.const(current.args.name, current.args.format) }, object(tree, options) { const { current, schema, name } = tree const properties = Object.entries(current.args?.properties || {}) .filter((item) => { const schemas = item[1] return schemas && typeof schemas.map === 'function' }) .map(([name, schemas]) => { const nameSchema = schemas.find((schema) => schema.keyword === schemaKeywords.name) as SchemaKeywordMapper['name'] const mappedName = nameSchema?.args || name // custom mapper(pluginOptions) // Use Object.hasOwn to avoid matching inherited properties like 'toString', 'valueOf', etc. if (options.mapper && Object.hasOwn(options.mapper, mappedName)) { return options.mapper[mappedName] } const isNullish = schemas.some((schema) => schema.keyword === schemaKeywords.nullish) const isNullable = schemas.some((schema) => schema.keyword === schemaKeywords.nullable) const isOptional = schemas.some((schema) => schema.keyword === schemaKeywords.optional) const isReadonly = schemas.some((schema) => schema.keyword === schemaKeywords.readOnly) const describeSchema = schemas.find((schema) => schema.keyword === schemaKeywords.describe) as SchemaKeywordMapper['describe'] | undefined const deprecatedSchema = schemas.find((schema) => schema.keyword === schemaKeywords.deprecated) as SchemaKeywordMapper['deprecated'] | undefined const defaultSchema = schemas.find((schema) => schema.keyword === schemaKeywords.default) as SchemaKeywordMapper['default'] | undefined const exampleSchema = schemas.find((schema) => schema.keyword === schemaKeywords.example) as SchemaKeywordMapper['example'] | undefined const schemaSchema = schemas.find((schema) => schema.keyword === schemaKeywords.schema) as SchemaKeywordMapper['schema'] | undefined const minSchema = schemas.find((schema) => schema.keyword === schemaKeywords.min) as SchemaKeywordMapper['min'] | undefined const maxSchema = schemas.find((schema) => schema.keyword === schemaKeywords.max) as SchemaKeywordMapper['max'] | undefined const matchesSchema = schemas.find((schema) => schema.keyword === schemaKeywords.matches) as SchemaKeywordMapper['matches'] | undefined let type = schemas .map((it) => this.parse( { schema, parent: current, name, current: it, siblings: schemas, }, options, ), ) .filter(Boolean)[0] as ts.TypeNode if (isNullable) { type = factory.createUnionDeclaration({ nodes: [type, factory.keywordTypeNodes.null], }) as ts.TypeNode } if (isNullish && ['undefined', 'questionTokenAndUndefined'].includes(options.optionalType as string)) { type = factory.createUnionDeclaration({ nodes: [type, factory.keywordTypeNodes.undefined], }) as ts.TypeNode } if (isOptional && ['undefined', 'questionTokenAndUndefined'].includes(options.optionalType as string)) { type = factory.createUnionDeclaration({ nodes: [type, factory.keywordTypeNodes.undefined], }) as ts.TypeNode } const propertyNode = factory.createPropertySignature({ questionToken: isOptional || isNullish ? ['questionToken', 'questionTokenAndUndefined'].includes(options.optionalType as string) : false, name: mappedName, type, readOnly: isReadonly, }) return factory.appendJSDocToNode({ node: propertyNode, comments: [ describeSchema ? `@description ${transformers.jsStringEscape(describeSchema.args)}` : undefined, deprecatedSchema ? '@deprecated' : undefined, minSchema ? `@minLength ${minSchema.args}` : undefined, maxSchema ? `@maxLength ${maxSchema.args}` : undefined, matchesSchema ? `@pattern ${matchesSchema.args}` : undefined, defaultSchema ? `@default ${defaultSchema.args}` : undefined, exampleSchema ? `@example ${exampleSchema.args}` : undefined, schemaSchema?.args?.type || schemaSchema?.args?.format ? [`@type ${schemaSchema?.args?.type || 'unknown'}${!isOptional ? '' : ' | undefined'}`, schemaSchema?.args?.format].filter(Boolean).join(', ') : undefined, ].filter(Boolean), }) }) let additionalProperties: any if (current.args?.additionalProperties?.length) { let additionalPropertiesType = current.args.additionalProperties .map((it) => this.parse({ schema, parent: current, name, current: it, siblings: [] }, options)) .filter(Boolean) .at(0) as ts.TypeNode const isNullable = current.args?.additionalProperties.some((schema) => isKeyword(schema, schemaKeywords.nullable)) if (isNullable) { additionalPropertiesType = factory.createUnionDeclaration({ nodes: [additionalPropertiesType, factory.keywordTypeNodes.null], }) as ts.TypeNode } // When there are typed properties alongside additionalProperties, use 'unknown' type // for the index signature to avoid TS2411 errors (index signature type conflicts with property types). // This occurs commonly in QueryParams where some params are typed (enums, objects) and // others are dynamic (additionalProperties with explode=true). const hasTypedProperties = properties.length > 0 const indexSignatureType = hasTypedProperties ? factory.keywordTypeNodes.unknown : additionalPropertiesType additionalProperties = factory.createIndexSignature(indexSignatureType) } let patternProperties: ts.TypeNode | ts.IndexSignatureDeclaration | undefined if (current.args?.patternProperties) { const allPatternSchemas = Object.values(current.args.patternProperties).flat() if (allPatternSchemas.length > 0) { patternProperties = allPatternSchemas .map((it) => this.parse({ schema, parent: current, name, current: it, siblings: [] }, options)) .filter(Boolean) .at(0) as ts.TypeNode const isNullable = allPatternSchemas.some((schema) => isKeyword(schema, schemaKeywords.nullable)) if (isNullable) { patternProperties = factory.createUnionDeclaration({ nodes: [patternProperties, factory.keywordTypeNodes.null], }) as ts.TypeNode } patternProperties = factory.createIndexSignature(patternProperties) } } return typeKeywordMapper.object([...properties, additionalProperties, patternProperties].filter(Boolean)) }, datetime() { return typeKeywordMapper.datetime() }, date(tree) { const { current } = tree return typeKeywordMapper.date(current.args.type) }, time(tree) { const { current } = tree return typeKeywordMapper.time(current.args.type) }, }, })