@kubb/plugin-ts
Version:
TypeScript code generation plugin for Kubb, transforming OpenAPI schemas into TypeScript interfaces, types, and utility functions.
227 lines (193 loc) • 9.52 kB
text/typescript
import { jsStringEscape, stringify } from '@internals/utils'
import type { ArraySchemaNode, SchemaNode } from '@kubb/ast/types'
import type { PrinterFactoryOptions } from '@kubb/core'
import { definePrinter } from '@kubb/core'
import type ts from 'typescript'
import * as factory from './factory.ts'
type TsOptions = {
/**
* @default `'questionToken'`
*/
optionalType: 'questionToken' | 'undefined' | 'questionTokenAndUndefined'
/**
* @default `'array'`
*/
arrayType: 'array' | 'generic'
/**
* @default `'inlineLiteral'`
*/
enumType: 'enum' | 'asConst' | 'asPascalConst' | 'constEnum' | 'literal' | 'inlineLiteral'
/**
* @default `'Key'`
*/
enumTypeSuffix?: string
}
type TsPrinter = PrinterFactoryOptions<'typescript', TsOptions, ts.TypeNode>
function constToTypeNode(value: string | number | boolean, format: 'string' | 'number' | 'boolean'): ts.TypeNode | undefined {
if (format === 'boolean') {
return factory.createLiteralTypeNode(value === true ? factory.createTrue() : factory.createFalse())
}
if (format === 'number' && typeof value === 'number') {
if (value < 0) {
return factory.createLiteralTypeNode(factory.createPrefixUnaryExpression(factory.SyntaxKind.MinusToken, factory.createNumericLiteral(Math.abs(value))))
}
return factory.createLiteralTypeNode(factory.createNumericLiteral(value))
}
return factory.createLiteralTypeNode(factory.createStringLiteral(String(value)))
}
function dateOrStringNode(node: { representation?: string }): ts.TypeNode {
return node.representation === 'date' ? factory.createTypeReferenceNode(factory.createIdentifier('Date')) : factory.keywordTypeNodes.string
}
function buildMemberNodes(members: Array<SchemaNode> | undefined, print: (node: SchemaNode) => ts.TypeNode | null | undefined): Array<ts.TypeNode> {
return (members ?? []).map(print).filter(Boolean) as Array<ts.TypeNode>
}
function buildTupleNode(node: ArraySchemaNode, print: (node: SchemaNode) => ts.TypeNode | null | undefined): ts.TypeNode | undefined {
let items = (node.items ?? []).map(print).filter(Boolean) as Array<ts.TypeNode>
const restNode = node.rest ? (print(node.rest) ?? undefined) : undefined
const { min, max } = node
if (max !== undefined) {
items = items.slice(0, max)
if (items.length < max && restNode) {
items = [...items, ...Array(max - items.length).fill(restNode)]
}
}
if (min !== undefined) {
items = items.map((item, i) => (i >= min ? factory.createOptionalTypeNode(item) : item))
}
if (max === undefined && restNode) {
items.push(factory.createRestTypeNode(factory.createArrayTypeNode(restNode)))
}
return factory.createTupleTypeNode(items)
}
function buildPropertyType(schema: SchemaNode, baseType: ts.TypeNode, optionalType: TsOptions['optionalType']): ts.TypeNode {
const addsUndefined = ['undefined', 'questionTokenAndUndefined'].includes(optionalType)
let type = baseType
if (schema.nullable) {
type = factory.createUnionDeclaration({ nodes: [type, factory.keywordTypeNodes.null] }) as ts.TypeNode
}
if ((schema.nullish || schema.optional) && addsUndefined) {
type = factory.createUnionDeclaration({ nodes: [type, factory.keywordTypeNodes.undefined] }) as ts.TypeNode
}
return type
}
function buildPropertyJSDocComments(schema: SchemaNode): Array<string | undefined> {
return [
'description' in schema && schema.description ? `@description ${jsStringEscape(schema.description as string)}` : undefined,
'deprecated' in schema && schema.deprecated ? '@deprecated' : undefined,
'min' in schema && schema.min !== undefined ? `@minLength ${schema.min}` : undefined,
'max' in schema && schema.max !== undefined ? `@maxLength ${schema.max}` : undefined,
'pattern' in schema && schema.pattern ? `@pattern ${schema.pattern}` : undefined,
'default' in schema && schema.default !== undefined
? `@default ${'primitive' in schema && schema.primitive === 'string' ? stringify(schema.default as string) : schema.default}`
: undefined,
'example' in schema && schema.example !== undefined ? `@example ${schema.example}` : undefined,
'primitive' in schema && schema.primitive
? [`@type ${schema.primitive || 'unknown'}`, 'optional' in schema && schema.optional ? ' | undefined' : undefined].filter(Boolean).join('')
: undefined,
]
}
function buildIndexSignatures(
node: { additionalProperties?: SchemaNode | boolean; patternProperties?: Record<string, SchemaNode> },
propertyCount: number,
print: (node: SchemaNode) => ts.TypeNode | null | undefined,
): Array<ts.TypeElement> {
const elements: Array<ts.TypeElement> = []
if (node.additionalProperties && node.additionalProperties !== true) {
const additionalType = (print(node.additionalProperties as SchemaNode) ?? factory.keywordTypeNodes.unknown) as ts.TypeNode
elements.push(factory.createIndexSignature(propertyCount > 0 ? factory.keywordTypeNodes.unknown : additionalType))
} else if (node.additionalProperties === true) {
elements.push(factory.createIndexSignature(factory.keywordTypeNodes.unknown))
}
if (node.patternProperties) {
const first = Object.values(node.patternProperties)[0]
if (first) {
let patternType = (print(first) ?? factory.keywordTypeNodes.unknown) as ts.TypeNode
if (first.nullable) {
patternType = factory.createUnionDeclaration({ nodes: [patternType, factory.keywordTypeNodes.null] }) as ts.TypeNode
}
elements.push(factory.createIndexSignature(patternType))
}
}
return elements
}
/**
* Converts a `SchemaNode` AST node into a TypeScript `ts.TypeNode`.
*
* Built on `definePrinter` — dispatches on `node.type`, with options closed over
* per printer instance. Produces the same `ts.TypeNode` output as the keyword-based
* `parse` in `parser.ts`.
*/
export const printerTs = definePrinter<TsPrinter>((options) => ({
name: 'typescript',
options,
nodes: {
any: () => factory.keywordTypeNodes.any,
unknown: () => factory.keywordTypeNodes.unknown,
void: () => factory.keywordTypeNodes.void,
boolean: () => factory.keywordTypeNodes.boolean,
null: () => factory.keywordTypeNodes.null,
blob: () => factory.createTypeReferenceNode('Blob', []),
string: () => factory.keywordTypeNodes.string,
uuid: () => factory.keywordTypeNodes.string,
email: () => factory.keywordTypeNodes.string,
url: () => factory.keywordTypeNodes.string,
datetime: () => factory.keywordTypeNodes.string,
number: () => factory.keywordTypeNodes.number,
integer: () => factory.keywordTypeNodes.number,
bigint: () => factory.keywordTypeNodes.bigint,
date: (node) => dateOrStringNode(node),
time: (node) => dateOrStringNode(node),
ref(node) {
if (!node.name) {
return undefined
}
return factory.createTypeReferenceNode(node.name, undefined)
},
enum(node) {
const values = node.namedEnumValues?.map((v) => v.value) ?? node.enumValues ?? []
if (this.options.enumType === 'inlineLiteral' || !node.name) {
const literalNodes = values
.filter((v): v is string | number | boolean => v !== null)
.map((value) => constToTypeNode(value, typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'boolean' : 'string'))
.filter(Boolean) as Array<ts.TypeNode>
return factory.createUnionDeclaration({ withParentheses: true, nodes: literalNodes }) ?? undefined
}
const enumTypeSuffix = this.options.enumTypeSuffix ?? 'Key'
const typeName = ['asConst', 'asPascalConst'].includes(this.options.enumType) ? `${node.name}${enumTypeSuffix}` : node.name
return factory.createTypeReferenceNode(typeName, undefined)
},
union(node) {
return factory.createUnionDeclaration({ withParentheses: true, nodes: buildMemberNodes(node.members, this.print) }) ?? undefined
},
intersection(node) {
return factory.createIntersectionDeclaration({ withParentheses: true, nodes: buildMemberNodes(node.members, this.print) }) ?? undefined
},
array(node) {
const itemNodes = (node.items ?? []).map((item) => this.print(item)).filter(Boolean) as Array<ts.TypeNode>
return factory.createArrayDeclaration({ nodes: itemNodes, arrayType: this.options.arrayType }) ?? undefined
},
tuple(node) {
return buildTupleNode(node, this.print)
},
object(node) {
const addsQuestionToken = ['questionToken', 'questionTokenAndUndefined'].includes(this.options.optionalType)
const { print } = this
const propertyNodes: Array<ts.TypeElement> = node.properties.map((prop) => {
const baseType = (print(prop.schema) ?? factory.keywordTypeNodes.unknown) as ts.TypeNode
const type = buildPropertyType(prop.schema, baseType, this.options.optionalType)
const propertyNode = factory.createPropertySignature({
questionToken: prop.schema.optional || prop.schema.nullish ? addsQuestionToken : false,
name: prop.name,
type,
readOnly: prop.schema.readOnly,
})
return factory.appendJSDocToNode({ node: propertyNode, comments: buildPropertyJSDocComments(prop.schema) })
})
const allElements = [...propertyNodes, ...buildIndexSignatures(node, propertyNodes.length, print)]
if (!allElements.length) {
return factory.keywordTypeNodes.object
}
return factory.createTypeLiteralNode(allElements)
},
},
}))