@kubb/plugin-ts
Version:
TypeScript code generation plugin for Kubb, transforming OpenAPI schemas into TypeScript interfaces, types, and utility functions.
700 lines (608 loc) • 22.4 kB
text/typescript
import transformers from '@kubb/core/transformers'
import { orderBy } from 'natural-orderby'
import { isNumber } from 'remeda'
import ts from 'typescript'
const { SyntaxKind, factory } = ts
// https://ts-ast-viewer.com/
export const modifiers = {
async: factory.createModifier(ts.SyntaxKind.AsyncKeyword),
export: factory.createModifier(ts.SyntaxKind.ExportKeyword),
const: factory.createModifier(ts.SyntaxKind.ConstKeyword),
static: factory.createModifier(ts.SyntaxKind.StaticKeyword),
} as const
export const syntaxKind = {
union: SyntaxKind.UnionType as 192,
} as const
export function getUnknownType(unknownType: 'any' | 'unknown' | 'void' | undefined) {
if (unknownType === 'any') {
return keywordTypeNodes.any
}
if (unknownType === 'void') {
return keywordTypeNodes.void
}
return keywordTypeNodes.unknown
}
function isValidIdentifier(str: string): boolean {
if (!str.length || str.trim() !== str) {
return false
}
const node = ts.parseIsolatedEntityName(str, ts.ScriptTarget.Latest)
return !!node && node.kind === ts.SyntaxKind.Identifier && ts.identifierToKeywordKind(node.kind as unknown as ts.Identifier) === undefined
}
function propertyName(name: string | ts.PropertyName): ts.PropertyName {
if (typeof name === 'string') {
const isValid = isValidIdentifier(name)
return isValid ? factory.createIdentifier(name) : factory.createStringLiteral(name)
}
return name
}
const questionToken = factory.createToken(ts.SyntaxKind.QuestionToken)
export function createQuestionToken(token?: boolean | ts.QuestionToken) {
if (!token) {
return undefined
}
if (token === true) {
return questionToken
}
return token
}
export function createIntersectionDeclaration({ nodes, withParentheses }: { nodes: Array<ts.TypeNode>; withParentheses?: boolean }): ts.TypeNode | null {
if (!nodes.length) {
return null
}
if (nodes.length === 1) {
return nodes[0] || null
}
const node = factory.createIntersectionTypeNode(nodes)
if (withParentheses) {
return factory.createParenthesizedType(node)
}
return node
}
/**
* Minimum nodes length of 2
* @example `string & number`
*/
export function createTupleDeclaration({ nodes, withParentheses }: { nodes: Array<ts.TypeNode>; withParentheses?: boolean }): ts.TypeNode | null {
if (!nodes.length) {
return null
}
if (nodes.length === 1) {
return nodes[0] || null
}
const node = factory.createTupleTypeNode(nodes)
if (withParentheses) {
return factory.createParenthesizedType(node)
}
return node
}
export function createArrayDeclaration({ nodes, arrayType = 'array' }: { nodes: Array<ts.TypeNode>; arrayType?: 'array' | 'generic' }): ts.TypeNode | null {
if (!nodes.length) {
return factory.createTupleTypeNode([])
}
if (nodes.length === 1) {
const node = nodes[0]
if (!node) {
return null
}
if (arrayType === 'generic') {
return factory.createTypeReferenceNode(factory.createIdentifier('Array'), [node])
}
return factory.createArrayTypeNode(node)
}
// For union types (multiple nodes), respect arrayType preference
const unionType = factory.createUnionTypeNode(nodes)
if (arrayType === 'generic') {
return factory.createTypeReferenceNode(factory.createIdentifier('Array'), [unionType])
}
// For array syntax with unions, we need parentheses: (string | number)[]
return factory.createArrayTypeNode(factory.createParenthesizedType(unionType))
}
/**
* Minimum nodes length of 2
* @example `string | number`
*/
export function createUnionDeclaration({ nodes, withParentheses }: { nodes: Array<ts.TypeNode>; withParentheses?: boolean }): ts.TypeNode {
if (!nodes.length) {
return keywordTypeNodes.any
}
if (nodes.length === 1) {
return nodes[0] as ts.TypeNode
}
const node = factory.createUnionTypeNode(nodes)
if (withParentheses) {
return factory.createParenthesizedType(node)
}
return node
}
export function createPropertySignature({
readOnly,
modifiers = [],
name,
questionToken,
type,
}: {
readOnly?: boolean
modifiers?: Array<ts.Modifier>
name: ts.PropertyName | string
questionToken?: ts.QuestionToken | boolean
type?: ts.TypeNode
}) {
return factory.createPropertySignature(
[...modifiers, readOnly ? factory.createToken(ts.SyntaxKind.ReadonlyKeyword) : undefined].filter(Boolean),
propertyName(name),
createQuestionToken(questionToken),
type,
)
}
export function createParameterSignature(
name: string | ts.BindingName,
{
modifiers,
dotDotDotToken,
questionToken,
type,
initializer,
}: {
decorators?: Array<ts.Decorator>
modifiers?: Array<ts.Modifier>
dotDotDotToken?: ts.DotDotDotToken
questionToken?: ts.QuestionToken | boolean
type?: ts.TypeNode
initializer?: ts.Expression
},
): ts.ParameterDeclaration {
return factory.createParameterDeclaration(modifiers, dotDotDotToken, name, createQuestionToken(questionToken), type, initializer)
}
export function createJSDoc({ comments }: { comments: string[] }) {
if (!comments.length) {
return null
}
return factory.createJSDocComment(
factory.createNodeArray(
comments.map((comment, i) => {
if (i === comments.length - 1) {
return factory.createJSDocText(comment)
}
return factory.createJSDocText(`${comment}\n`)
}),
),
)
}
/**
* @link https://github.com/microsoft/TypeScript/issues/44151
*/
export function appendJSDocToNode<TNode extends ts.Node>({ node, comments }: { node: TNode; comments: Array<string | undefined> }) {
const filteredComments = comments.filter(Boolean)
if (!filteredComments.length) {
return node
}
const text = filteredComments.reduce((acc = '', comment = '') => {
return `${acc}\n * ${comment.replaceAll('*/', '*\\/')}`
}, '*')
// Use the node directly instead of spreading to avoid creating Unknown nodes
// TypeScript's addSyntheticLeadingComment accepts the node as-is
return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.MultiLineCommentTrivia, `${text || '*'}\n`, true)
}
export function createIndexSignature(
type: ts.TypeNode,
{
modifiers,
indexName = 'key',
indexType = factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
}: {
indexName?: string
indexType?: ts.TypeNode
decorators?: Array<ts.Decorator>
modifiers?: Array<ts.Modifier>
} = {},
) {
return factory.createIndexSignature(modifiers, [createParameterSignature(indexName, { type: indexType })], type)
}
export function createTypeAliasDeclaration({
modifiers,
name,
typeParameters,
type,
}: {
modifiers?: Array<ts.Modifier>
name: string | ts.Identifier
typeParameters?: Array<ts.TypeParameterDeclaration>
type: ts.TypeNode
}) {
return factory.createTypeAliasDeclaration(modifiers, name, typeParameters, type)
}
export function createInterfaceDeclaration({
modifiers,
name,
typeParameters,
members,
}: {
modifiers?: Array<ts.Modifier>
name: string | ts.Identifier
typeParameters?: Array<ts.TypeParameterDeclaration>
members: Array<ts.TypeElement>
}) {
return factory.createInterfaceDeclaration(modifiers, name, typeParameters, undefined, members)
}
export function createTypeDeclaration({
syntax,
isExportable,
comments,
name,
type,
}: {
syntax: 'type' | 'interface'
comments: Array<string | undefined>
isExportable?: boolean
name: string | ts.Identifier
type: ts.TypeNode
}) {
if (syntax === 'interface' && 'members' in type) {
const node = createInterfaceDeclaration({
members: type.members as Array<ts.TypeElement>,
modifiers: isExportable ? [modifiers.export] : [],
name,
typeParameters: undefined,
})
return appendJSDocToNode({
node,
comments,
})
}
const node = createTypeAliasDeclaration({
type,
modifiers: isExportable ? [modifiers.export] : [],
name,
typeParameters: undefined,
})
return appendJSDocToNode({
node,
comments,
})
}
export function createNamespaceDeclaration({ statements, name }: { name: string; statements: ts.Statement[] }) {
return factory.createModuleDeclaration(
[factory.createToken(ts.SyntaxKind.ExportKeyword)],
factory.createIdentifier(name),
factory.createModuleBlock(statements),
ts.NodeFlags.Namespace,
)
}
/**
* In { propertyName: string; name?: string } is `name` being used to make the type more unique when multiple same names are used.
* @example `import { Pet as Cat } from './Pet'`
*/
export function createImportDeclaration({
name,
path,
isTypeOnly = false,
isNameSpace = false,
}: {
name: string | Array<string | { propertyName: string; name?: string }>
path: string
isTypeOnly?: boolean
isNameSpace?: boolean
}) {
if (!Array.isArray(name)) {
let importPropertyName: ts.Identifier | undefined = factory.createIdentifier(name)
let importName: ts.NamedImportBindings | undefined
if (isNameSpace) {
importPropertyName = undefined
importName = factory.createNamespaceImport(factory.createIdentifier(name))
}
return factory.createImportDeclaration(
undefined,
factory.createImportClause(isTypeOnly, importPropertyName, importName),
factory.createStringLiteral(path),
undefined,
)
}
// Sort the imports alphabetically for consistent output across platforms
const sortedName = orderBy(name, [(item) => (typeof item === 'object' ? item.propertyName : item)])
return factory.createImportDeclaration(
undefined,
factory.createImportClause(
isTypeOnly,
undefined,
factory.createNamedImports(
sortedName.map((item) => {
if (typeof item === 'object') {
const obj = item as { propertyName: string; name?: string }
if (obj.name) {
return factory.createImportSpecifier(false, factory.createIdentifier(obj.propertyName), factory.createIdentifier(obj.name))
}
return factory.createImportSpecifier(false, undefined, factory.createIdentifier(obj.propertyName))
}
return factory.createImportSpecifier(false, undefined, factory.createIdentifier(item))
}),
),
),
factory.createStringLiteral(path),
undefined,
)
}
export function createExportDeclaration({
path,
asAlias,
isTypeOnly = false,
name,
}: {
path: string
asAlias?: boolean
isTypeOnly?: boolean
name?: string | Array<ts.Identifier | string>
}) {
if (name && !Array.isArray(name) && !asAlias) {
console.warn(`When using name as string, asAlias should be true ${name}`)
}
if (!Array.isArray(name)) {
const parsedName = name?.match(/^\d/) ? `_${name?.slice(1)}` : name
return factory.createExportDeclaration(
undefined,
isTypeOnly,
asAlias && parsedName ? factory.createNamespaceExport(factory.createIdentifier(parsedName)) : undefined,
factory.createStringLiteral(path),
undefined,
)
}
// Sort the exports alphabetically for consistent output across platforms
const sortedName = orderBy(name, [(propertyName) => (typeof propertyName === 'string' ? propertyName : propertyName.text)])
return factory.createExportDeclaration(
undefined,
isTypeOnly,
factory.createNamedExports(
sortedName.map((propertyName) => {
return factory.createExportSpecifier(false, undefined, typeof propertyName === 'string' ? factory.createIdentifier(propertyName) : propertyName)
}),
),
factory.createStringLiteral(path),
undefined,
)
}
/**
* Apply casing transformation to enum keys
*/
function applyEnumKeyCasing(key: string, casing: 'screamingSnakeCase' | 'snakeCase' | 'pascalCase' | 'camelCase' | 'none' = 'none'): string {
if (casing === 'none') {
return key
}
if (casing === 'screamingSnakeCase') {
return transformers.screamingSnakeCase(key)
}
if (casing === 'snakeCase') {
return transformers.snakeCase(key)
}
if (casing === 'pascalCase') {
return transformers.pascalCase(key)
}
if (casing === 'camelCase') {
return transformers.camelCase(key)
}
return key
}
export function createEnumDeclaration({
type = 'enum',
name,
typeName,
enums,
enumKeyCasing = 'none',
}: {
/**
* Choose to use `enum`, `asConst`, `asPascalConst`, `constEnum`, or `literal` 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
* @default `'enum'`
*/
type?: 'enum' | 'asConst' | 'asPascalConst' | 'constEnum' | 'literal' | 'inlineLiteral'
/**
* Enum name in camelCase.
*/
name: string
/**
* Enum name in PascalCase.
*/
typeName: string
enums: [key: string | number, value: string | number | boolean][]
/**
* Choose the casing for enum key names.
* @default 'none'
*/
enumKeyCasing?: 'screamingSnakeCase' | 'snakeCase' | 'pascalCase' | 'camelCase' | 'none'
}): [name: ts.Node | undefined, type: ts.Node] {
if (type === 'literal' || type === 'inlineLiteral') {
return [
undefined,
factory.createTypeAliasDeclaration(
[factory.createToken(ts.SyntaxKind.ExportKeyword)],
factory.createIdentifier(typeName),
undefined,
factory.createUnionTypeNode(
enums
.map(([_key, value]) => {
if (isNumber(value)) {
return factory.createLiteralTypeNode(factory.createNumericLiteral(value?.toString()))
}
if (typeof value === 'boolean') {
return factory.createLiteralTypeNode(value ? factory.createTrue() : factory.createFalse())
}
if (value) {
return factory.createLiteralTypeNode(factory.createStringLiteral(value.toString()))
}
return undefined
})
.filter(Boolean),
),
),
]
}
if (type === 'enum' || type === 'constEnum') {
return [
undefined,
factory.createEnumDeclaration(
[factory.createToken(ts.SyntaxKind.ExportKeyword), type === 'constEnum' ? factory.createToken(ts.SyntaxKind.ConstKeyword) : undefined].filter(Boolean),
factory.createIdentifier(typeName),
enums
.map(([key, value]) => {
let initializer: ts.Expression = factory.createStringLiteral(value?.toString())
const isExactNumber = Number.parseInt(value.toString(), 10) === value
if (isExactNumber && isNumber(Number.parseInt(value.toString(), 10))) {
initializer = factory.createNumericLiteral(value as number)
}
if (typeof value === 'boolean') {
initializer = value ? factory.createTrue() : factory.createFalse()
}
if (isNumber(Number.parseInt(key.toString(), 10))) {
const casingKey = applyEnumKeyCasing(`${typeName}_${key}`, enumKeyCasing)
return factory.createEnumMember(propertyName(casingKey), initializer)
}
if (key) {
const casingKey = applyEnumKeyCasing(key.toString(), enumKeyCasing)
return factory.createEnumMember(propertyName(casingKey), initializer)
}
return undefined
})
.filter(Boolean),
),
]
}
// used when using `as const` instead of an TypeScript enum.
const identifierName = type === 'asPascalConst' ? typeName : name
return [
factory.createVariableStatement(
[factory.createToken(ts.SyntaxKind.ExportKeyword)],
factory.createVariableDeclarationList(
[
factory.createVariableDeclaration(
factory.createIdentifier(identifierName),
undefined,
undefined,
factory.createAsExpression(
factory.createObjectLiteralExpression(
enums
.map(([key, value]) => {
let initializer: ts.Expression = factory.createStringLiteral(value?.toString())
if (isNumber(value)) {
// Error: Negative numbers should be created in combination with createPrefixUnaryExpression factory.
// The method createNumericLiteral only accepts positive numbers
// or those combined with createPrefixUnaryExpression.
// Therefore, we need to ensure that the number is not negative.
if (value < 0) {
initializer = factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, factory.createNumericLiteral(Math.abs(value)))
} else {
initializer = factory.createNumericLiteral(value)
}
}
if (typeof value === 'boolean') {
initializer = value ? factory.createTrue() : factory.createFalse()
}
if (key) {
const casingKey = applyEnumKeyCasing(key.toString(), enumKeyCasing)
return factory.createPropertyAssignment(propertyName(casingKey), initializer)
}
return undefined
})
.filter(Boolean),
true,
),
factory.createTypeReferenceNode(factory.createIdentifier('const'), undefined),
),
),
],
ts.NodeFlags.Const,
),
),
factory.createTypeAliasDeclaration(
type === 'asPascalConst' ? [] : [factory.createToken(ts.SyntaxKind.ExportKeyword)],
factory.createIdentifier(typeName),
undefined,
factory.createIndexedAccessTypeNode(
factory.createParenthesizedType(factory.createTypeQueryNode(factory.createIdentifier(identifierName), undefined)),
factory.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, factory.createTypeQueryNode(factory.createIdentifier(identifierName), undefined)),
),
),
]
}
export function createOmitDeclaration({ keys, type, nonNullable }: { keys: Array<string> | string; type: ts.TypeNode; nonNullable?: boolean }) {
const node = nonNullable ? factory.createTypeReferenceNode(factory.createIdentifier('NonNullable'), [type]) : type
if (Array.isArray(keys)) {
return factory.createTypeReferenceNode(factory.createIdentifier('Omit'), [
node,
factory.createUnionTypeNode(
keys.map((key) => {
return factory.createLiteralTypeNode(factory.createStringLiteral(key))
}),
),
])
}
return factory.createTypeReferenceNode(factory.createIdentifier('Omit'), [node, factory.createLiteralTypeNode(factory.createStringLiteral(keys))])
}
export const keywordTypeNodes = {
any: factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
unknown: factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
void: factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword),
number: factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
integer: factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
object: factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword),
string: factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
boolean: factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword),
undefined: factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword),
null: factory.createLiteralTypeNode(factory.createToken(ts.SyntaxKind.NullKeyword)),
never: factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword),
} as const
/**
* Converts a path like '/pet/{petId}/uploadImage' to a template literal type
* like `/pet/${string}/uploadImage`
*/
export function createUrlTemplateType(path: string): ts.TypeNode {
// If no parameters, return literal string type
if (!path.includes('{')) {
return factory.createLiteralTypeNode(factory.createStringLiteral(path))
}
// Split path by parameter placeholders, e.g. '/pet/{petId}/upload' -> ['/pet/', 'petId', '/upload']
const segments = path.split(/(\{[^}]+\})/)
// Separate static parts from parameter placeholders
const parts: string[] = []
const parameterIndices: number[] = []
segments.forEach((segment) => {
if (segment.startsWith('{') && segment.endsWith('}')) {
// This is a parameter placeholder
parameterIndices.push(parts.length)
parts.push(segment) // Will be replaced with ${string}
} else if (segment) {
// This is a static part
parts.push(segment)
}
})
// Build template literal type
// Template literal structure: head + templateSpans[]
// For '/pet/{petId}/upload': head = '/pet/', spans = [{ type: string, literal: '/upload' }]
const head = ts.factory.createTemplateHead(parts[0] || '')
const templateSpans: ts.TemplateLiteralTypeSpan[] = []
parameterIndices.forEach((paramIndex, i) => {
const isLast = i === parameterIndices.length - 1
const nextPart = parts[paramIndex + 1] || ''
const literal = isLast ? ts.factory.createTemplateTail(nextPart) : ts.factory.createTemplateMiddle(nextPart)
templateSpans.push(ts.factory.createTemplateLiteralTypeSpan(keywordTypeNodes.string, literal))
})
return ts.factory.createTemplateLiteralType(head, templateSpans)
}
export const createTypeLiteralNode = factory.createTypeLiteralNode
export const createTypeReferenceNode = factory.createTypeReferenceNode
export const createNumericLiteral = factory.createNumericLiteral
export const createStringLiteral = factory.createStringLiteral
export const createArrayTypeNode = factory.createArrayTypeNode
export const createParenthesizedType = factory.createParenthesizedType
export const createLiteralTypeNode = factory.createLiteralTypeNode
export const createNull = factory.createNull
export const createIdentifier = factory.createIdentifier
export const createOptionalTypeNode = factory.createOptionalTypeNode
export const createTupleTypeNode = factory.createTupleTypeNode
export const createRestTypeNode = factory.createRestTypeNode
export const createTrue = factory.createTrue
export const createFalse = factory.createFalse
export const createIndexedAccessTypeNode = factory.createIndexedAccessTypeNode
export const createTypeOperatorNode = factory.createTypeOperatorNode