617 lines
21 KiB
Plaintext
617 lines
21 KiB
Plaintext
import type { OasRef, Referenced } from "@redocly/openapi-core";
|
||
import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
|
||
import ts, { type LiteralTypeNode, type TypeLiteralNode } from "typescript";
|
||
import type { ParameterObject } from "../types.js";
|
||
|
||
export const JS_PROPERTY_INDEX_RE = /^[A-Za-z_$][A-Za-z_$0-9]*$/;
|
||
export const JS_ENUM_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+(.)?/g;
|
||
export const JS_PROPERTY_INDEX_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+/g;
|
||
export const SPECIAL_CHARACTER_MAP: Record<string, string> = {
|
||
"+": "Plus",
|
||
// Add more mappings as needed
|
||
};
|
||
|
||
export const BOOLEAN = ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
|
||
export const FALSE = ts.factory.createLiteralTypeNode(ts.factory.createFalse());
|
||
export const NEVER = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword);
|
||
export const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull());
|
||
export const NUMBER = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
|
||
export const QUESTION_TOKEN = ts.factory.createToken(ts.SyntaxKind.QuestionToken);
|
||
export const STRING = ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
|
||
export const TRUE = ts.factory.createLiteralTypeNode(ts.factory.createTrue());
|
||
export const UNDEFINED = ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword);
|
||
export const UNKNOWN = ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
|
||
|
||
const LB_RE = /\r?\n/g;
|
||
const COMMENT_RE = /\*\//g;
|
||
|
||
export interface AnnotatedSchemaObject {
|
||
const?: unknown; // jsdoc without value
|
||
default?: unknown; // jsdoc with value
|
||
deprecated?: boolean; // jsdoc without value
|
||
description?: string; // jsdoc with value
|
||
enum?: unknown[]; // jsdoc without value
|
||
example?: string; // jsdoc with value
|
||
examples?: unknown;
|
||
format?: string; // not jsdoc
|
||
nullable?: boolean; // Node information
|
||
summary?: string; // not jsdoc
|
||
title?: string; // not jsdoc
|
||
type?: string | string[]; // Type of node
|
||
}
|
||
|
||
/**
|
||
* Preparing comments from fields
|
||
* @see {comment} for output examples
|
||
* @returns void if not comments or jsdoc format comment string
|
||
*/
|
||
export function addJSDocComment(schemaObject: AnnotatedSchemaObject, node: ts.PropertySignature): void {
|
||
if (!schemaObject || typeof schemaObject !== "object" || Array.isArray(schemaObject)) {
|
||
return;
|
||
}
|
||
const output: string[] = [];
|
||
|
||
// Not JSDoc tags: [title, format]
|
||
if (schemaObject.title) {
|
||
output.push(schemaObject.title.trim().replace(LB_RE, "\n * "));
|
||
}
|
||
if (schemaObject.summary) {
|
||
output.push(schemaObject.summary.trim().replace(LB_RE, "\n * "));
|
||
}
|
||
if (schemaObject.format) {
|
||
output.push(`Format: ${schemaObject.format}`);
|
||
}
|
||
|
||
// JSDoc tags without value
|
||
// 'Deprecated' without value
|
||
if (schemaObject.deprecated) {
|
||
output.push("@deprecated");
|
||
}
|
||
|
||
// JSDoc tags with value
|
||
const supportedJsDocTags = ["description", "default", "example"] as const;
|
||
for (const field of supportedJsDocTags) {
|
||
const allowEmptyString = field === "default" || field === "example";
|
||
if (schemaObject[field] === undefined) {
|
||
continue;
|
||
}
|
||
if (schemaObject[field] === "" && !allowEmptyString) {
|
||
continue;
|
||
}
|
||
const serialized =
|
||
typeof schemaObject[field] === "object" ? JSON.stringify(schemaObject[field], null, 2) : schemaObject[field];
|
||
output.push(`@${field} ${String(serialized).trim().replace(LB_RE, "\n * ")}`);
|
||
}
|
||
|
||
if (Array.isArray(schemaObject.examples)) {
|
||
for (const example of schemaObject.examples) {
|
||
const serialized = typeof example === "object" ? JSON.stringify(example, null, 2) : example;
|
||
output.push(`@example ${String(serialized).trim().replace(LB_RE, "\n * ")}`);
|
||
}
|
||
}
|
||
|
||
// JSDoc 'Constant' without value
|
||
if ("const" in schemaObject) {
|
||
output.push("@constant");
|
||
}
|
||
|
||
// JSDoc 'Enum' with type
|
||
if (schemaObject.enum) {
|
||
let type = "unknown";
|
||
if (Array.isArray(schemaObject.type)) {
|
||
type = schemaObject.type.join("|");
|
||
} else if (typeof schemaObject.type === "string") {
|
||
type = schemaObject.type;
|
||
}
|
||
output.push(`@enum {${type}${schemaObject.nullable ? "|null" : ""}}`);
|
||
}
|
||
|
||
// attach comment if it has content
|
||
|
||
if (output.length) {
|
||
// Check if any output item contains multi-line content (has internal line breaks)
|
||
const hasMultiLineContent = output.some((item) => item.includes("\n"));
|
||
|
||
let text =
|
||
output.length === 1 && !hasMultiLineContent ? `* ${output.join("\n")} ` : `*\n * ${output.join("\n * ")}\n `;
|
||
text = text.replace(COMMENT_RE, "*\\/"); // prevent inner comments from leaking
|
||
|
||
ts.addSyntheticLeadingComment(
|
||
/* node */ node,
|
||
/* kind */ ts.SyntaxKind.MultiLineCommentTrivia, // note: MultiLine just refers to a "/* */" comment
|
||
/* text */ text,
|
||
/* hasTrailingNewLine */ true,
|
||
);
|
||
}
|
||
}
|
||
|
||
function isOasRef<T>(obj: Referenced<T>): obj is OasRef {
|
||
return Boolean((obj as OasRef).$ref);
|
||
}
|
||
type OapiRefResolved = Referenced<ParameterObject>;
|
||
|
||
function isParameterObject(obj: OapiRefResolved | undefined): obj is ParameterObject {
|
||
return Boolean(obj && !isOasRef(obj) && obj.in);
|
||
}
|
||
|
||
function addIndexedAccess(node: ts.TypeNode, ...segments: readonly string[]) {
|
||
return segments.reduce<ts.TypeNode>((acc, segment) => {
|
||
return ts.factory.createIndexedAccessTypeNode(
|
||
acc,
|
||
ts.factory.createLiteralTypeNode(
|
||
typeof segment === "number"
|
||
? ts.factory.createNumericLiteral(segment)
|
||
: ts.factory.createStringLiteral(segment),
|
||
),
|
||
);
|
||
}, node);
|
||
}
|
||
|
||
/**
|
||
* Wrap a type with Extract<T, { propertyName: unknown }> to narrow a union type
|
||
* before accessing a property that only exists on some variants.
|
||
*/
|
||
function wrapWithExtract(type: ts.TypeNode, propertyName: string): ts.TypeNode {
|
||
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Extract"), [
|
||
type,
|
||
ts.factory.createTypeLiteralNode([
|
||
ts.factory.createPropertySignature(
|
||
/* modifiers */ undefined,
|
||
/* name */ ts.factory.createIdentifier(propertyName),
|
||
/* questionToken */ undefined,
|
||
/* type */ ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
|
||
),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
export interface OapiRefOptions {
|
||
/** Whether to wrap with FlattenedDeepRequired<> (default: false) */
|
||
deep?: boolean;
|
||
/** Array of property names to wrap with Extract<> when accessing */
|
||
extractProperties?: string[];
|
||
}
|
||
|
||
/**
|
||
* Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`)
|
||
* `path` is a JSON Pointer to a location within an OpenAPI document.
|
||
* Transform it into a TypeScript type reference into the generated types.
|
||
*
|
||
* In most cases the structures of the openapi-typescript generated types and the
|
||
* JSON Pointer paths into the OpenAPI document are the same. However, in some cases
|
||
* special transformations are necessary to account for the ways they differ.
|
||
* * Object schemas
|
||
* $refs into the `properties` of object schemas are valid, but openapi-typescript
|
||
* flattens these objects, so we omit so the index into the schema skips ["properties"]
|
||
* * Parameters
|
||
* $refs into the `parameters` of paths are valid, but openapi-ts represents
|
||
* them according to their type; path, query, header, etc… so in these cases we
|
||
* must check the parameter definition to determine the how to index into
|
||
* the openapi-typescript type.
|
||
* * Union variant properties (oneOf/anyOf)
|
||
* When accessing properties that may only exist on some variants of a union type,
|
||
* we use Extract<> to narrow the type before each property access.
|
||
**/
|
||
export function oapiRef(path: string, resolved?: OapiRefResolved, options: OapiRefOptions = {}): ts.TypeNode {
|
||
const { pointer } = parseRef(path);
|
||
if (pointer.length === 0) {
|
||
throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`);
|
||
}
|
||
|
||
const parametersObject = isParameterObject(resolved);
|
||
const extractSet = new Set(options.extractProperties ?? []);
|
||
|
||
// Initial segments are handled in a fixed , then remaining segments are treated
|
||
// according to heuristics based on the initial segments
|
||
const initialSegment = pointer[0];
|
||
const leadingSegments = pointer.slice(1, 3);
|
||
const restSegments = pointer.slice(3);
|
||
|
||
const leadingType = addIndexedAccess(
|
||
ts.factory.createTypeReferenceNode(
|
||
ts.factory.createIdentifier(
|
||
options.deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment),
|
||
),
|
||
),
|
||
...leadingSegments,
|
||
);
|
||
|
||
return restSegments.reduce<ts.TypeNode>((acc, segment, index, original) => {
|
||
// Skip `properties` items when in the middle of the pointer
|
||
// See: https://github.com/openapi-ts/openapi-typescript/issues/1742
|
||
if (segment === "properties") {
|
||
return acc;
|
||
}
|
||
|
||
if (parametersObject && index === original.length - 1) {
|
||
return addIndexedAccess(acc, resolved.in, resolved.name);
|
||
}
|
||
|
||
// If this segment is in the extractProperties list,
|
||
// wrap the current type with Extract<T, { segment: unknown }> before accessing.
|
||
// This narrows union types to variants that have this property.
|
||
if (extractSet.has(segment)) {
|
||
const narrowedType = wrapWithExtract(acc, segment);
|
||
return addIndexedAccess(narrowedType, segment);
|
||
}
|
||
|
||
return addIndexedAccess(acc, segment);
|
||
}, leadingType);
|
||
}
|
||
|
||
export interface AstToStringOptions {
|
||
fileName?: string;
|
||
sourceText?: string;
|
||
formatOptions?: ts.PrinterOptions;
|
||
}
|
||
|
||
/** Convert TypeScript AST to string */
|
||
export function astToString(
|
||
ast: ts.Node | ts.Node[] | ts.TypeElement | ts.TypeElement[],
|
||
options?: AstToStringOptions,
|
||
): string {
|
||
const sourceFile = ts.createSourceFile(
|
||
options?.fileName ?? "openapi-ts.ts",
|
||
options?.sourceText ?? "",
|
||
ts.ScriptTarget.ESNext,
|
||
false,
|
||
ts.ScriptKind.TS,
|
||
);
|
||
|
||
// @ts-expect-error it’s OK to overwrite statements once
|
||
sourceFile.statements = ts.factory.createNodeArray(Array.isArray(ast) ? ast : [ast]);
|
||
|
||
const printer = ts.createPrinter({
|
||
newLine: ts.NewLineKind.LineFeed,
|
||
removeComments: false,
|
||
...options?.formatOptions,
|
||
});
|
||
return printer.printFile(sourceFile);
|
||
}
|
||
|
||
/** Convert an arbitrary string to TS (assuming it’s valid) */
|
||
export function stringToAST(source: string): unknown[] {
|
||
return ts.createSourceFile(
|
||
/* fileName */ "stringInput",
|
||
/* sourceText */ source,
|
||
/* languageVersion */ ts.ScriptTarget.ESNext,
|
||
/* setParentNodes */ undefined,
|
||
/* scriptKind */ undefined,
|
||
).statements as any;
|
||
}
|
||
|
||
/**
|
||
* Deduplicate simple primitive types from an array of nodes
|
||
* Note: won’t deduplicate complex types like objects
|
||
*/
|
||
export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] {
|
||
const encounteredTypes = new Set<number>();
|
||
const filteredTypes: ts.TypeNode[] = [];
|
||
for (const t of types) {
|
||
// only mark for deduplication if this is not a const ("text" means it is a const)
|
||
if (!("text" in ((t as LiteralTypeNode).literal ?? t))) {
|
||
const { kind } = (t as LiteralTypeNode).literal ?? t;
|
||
if (encounteredTypes.has(kind)) {
|
||
continue;
|
||
}
|
||
if (tsIsPrimitive(t)) {
|
||
encounteredTypes.add(kind);
|
||
}
|
||
}
|
||
filteredTypes.push(t);
|
||
}
|
||
return filteredTypes;
|
||
}
|
||
|
||
export const enumCache = new Map<string, ts.EnumDeclaration>();
|
||
|
||
/** Create a TS enum (with sanitized name and members) */
|
||
export function tsEnum(
|
||
name: string,
|
||
members: (string | number)[],
|
||
metadata?: { name?: string; description?: string | null }[],
|
||
options?: { export?: boolean; shouldCache?: boolean },
|
||
) {
|
||
let enumName = sanitizeMemberName(name);
|
||
enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`;
|
||
let key = "";
|
||
if (options?.shouldCache) {
|
||
key = `${members
|
||
.slice(0)
|
||
.sort()
|
||
.map((v, i) => {
|
||
return `${metadata?.[i]?.name ?? String(v)}:${metadata?.[i]?.description || ""}`;
|
||
})
|
||
.join(",")}`;
|
||
if (enumCache.has(key)) {
|
||
return enumCache.get(key) as ts.EnumDeclaration;
|
||
}
|
||
}
|
||
const enumDeclaration = ts.factory.createEnumDeclaration(
|
||
/* modifiers */ options ? tsModifiers({ export: options.export ?? false }) : undefined,
|
||
/* name */ enumName,
|
||
/* members */ members.map((value, i) => tsEnumMember(value, metadata?.[i])),
|
||
);
|
||
options?.shouldCache && enumCache.set(key, enumDeclaration);
|
||
return enumDeclaration;
|
||
}
|
||
|
||
/** Create an exported TS array literal expression */
|
||
export function tsArrayLiteralExpression(
|
||
name: string,
|
||
elementType: ts.TypeNode,
|
||
values: (string | number)[],
|
||
options?: { export?: boolean; readonly?: boolean; injectFooter?: ts.Node[] },
|
||
) {
|
||
let variableName = sanitizeMemberName(name);
|
||
variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`;
|
||
|
||
if (
|
||
options?.injectFooter &&
|
||
!options.injectFooter.some(
|
||
(node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "FlattenedDeepRequired",
|
||
)
|
||
) {
|
||
const helper = stringToAST(
|
||
"type FlattenedDeepRequired<T> = { [K in keyof T]-?: FlattenedDeepRequired<T[K] extends unknown[] | undefined | null ? Extract<T[K], unknown[]>[number] : T[K]>; };",
|
||
)[0] as any;
|
||
options.injectFooter.push(helper);
|
||
}
|
||
|
||
const arrayType = options?.readonly
|
||
? tsReadonlyArray(elementType, options.injectFooter)
|
||
: ts.factory.createArrayTypeNode(elementType);
|
||
|
||
return ts.factory.createVariableStatement(
|
||
options ? tsModifiers({ export: options.export ?? false }) : undefined,
|
||
ts.factory.createVariableDeclarationList(
|
||
[
|
||
ts.factory.createVariableDeclaration(
|
||
variableName,
|
||
undefined,
|
||
arrayType,
|
||
ts.factory.createArrayLiteralExpression(
|
||
values.map((value) => {
|
||
if (typeof value === "number") {
|
||
if (value < 0) {
|
||
return ts.factory.createPrefixUnaryExpression(
|
||
ts.SyntaxKind.MinusToken,
|
||
ts.factory.createNumericLiteral(Math.abs(value)),
|
||
);
|
||
} else {
|
||
return ts.factory.createNumericLiteral(value);
|
||
}
|
||
} else {
|
||
return ts.factory.createStringLiteral(value);
|
||
}
|
||
}),
|
||
),
|
||
),
|
||
],
|
||
ts.NodeFlags.Const,
|
||
),
|
||
);
|
||
}
|
||
|
||
function sanitizeMemberName(name: string) {
|
||
let sanitizedName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => {
|
||
const last = c[c.length - 1];
|
||
return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last) ? "" : last.toUpperCase();
|
||
});
|
||
if (Number(name[0]) >= 0) {
|
||
sanitizedName = `Value${name}`;
|
||
}
|
||
return sanitizedName;
|
||
}
|
||
|
||
/** Sanitize TS enum member expression */
|
||
export function tsEnumMember(value: string | number, metadata: { name?: string; description?: string | null } = {}) {
|
||
let name = metadata.name ?? String(value);
|
||
if (!JS_PROPERTY_INDEX_RE.test(name)) {
|
||
if (Number(name[0]) >= 0) {
|
||
name = `Value${name}`.replace(".", "_"); // don't forged decimals;
|
||
} else if (name[0] === "-") {
|
||
name = `ValueMinus${name.slice(1)}`;
|
||
}
|
||
|
||
const invalidCharMatch = name.match(JS_PROPERTY_INDEX_INVALID_CHARS_RE);
|
||
if (invalidCharMatch) {
|
||
if (invalidCharMatch[0] === name) {
|
||
name = `"${name}"`;
|
||
} else {
|
||
name = name.replace(JS_PROPERTY_INDEX_INVALID_CHARS_RE, (s) => {
|
||
return s in SPECIAL_CHARACTER_MAP ? SPECIAL_CHARACTER_MAP[s] : "_";
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
let member: ts.EnumMember;
|
||
if (typeof value === "number") {
|
||
const literal =
|
||
value < 0
|
||
? ts.factory.createPrefixUnaryExpression(
|
||
ts.SyntaxKind.MinusToken,
|
||
ts.factory.createNumericLiteral(Math.abs(value)),
|
||
)
|
||
: ts.factory.createNumericLiteral(value);
|
||
|
||
member = ts.factory.createEnumMember(name, literal);
|
||
} else {
|
||
member = ts.factory.createEnumMember(name, ts.factory.createStringLiteral(value));
|
||
}
|
||
|
||
const trimmedDescription = metadata.description?.trim();
|
||
if (trimmedDescription === undefined || trimmedDescription === null || trimmedDescription === "") {
|
||
return member;
|
||
}
|
||
|
||
return ts.addSyntheticLeadingComment(member, ts.SyntaxKind.SingleLineCommentTrivia, ` ${trimmedDescription}`, true);
|
||
}
|
||
|
||
/** Create an intersection type */
|
||
export function tsIntersection(types: ts.TypeNode[]): ts.TypeNode {
|
||
if (types.length === 0) {
|
||
return NEVER;
|
||
}
|
||
if (types.length === 1) {
|
||
return types[0];
|
||
}
|
||
return ts.factory.createIntersectionTypeNode(tsDedupe(types));
|
||
}
|
||
|
||
/** Is this a primitive type (string, number, boolean, null, undefined)? */
|
||
export function tsIsPrimitive(type: ts.TypeNode): boolean {
|
||
if (!type) {
|
||
return true;
|
||
}
|
||
return (
|
||
ts.SyntaxKind[type.kind] === "BooleanKeyword" ||
|
||
ts.SyntaxKind[type.kind] === "NeverKeyword" ||
|
||
ts.SyntaxKind[type.kind] === "NullKeyword" ||
|
||
ts.SyntaxKind[type.kind] === "NumberKeyword" ||
|
||
ts.SyntaxKind[type.kind] === "StringKeyword" ||
|
||
ts.SyntaxKind[type.kind] === "UndefinedKeyword" ||
|
||
("literal" in type && tsIsPrimitive(type.literal as TypeLiteralNode))
|
||
);
|
||
}
|
||
|
||
/** Create a literal type */
|
||
export function tsLiteral(value: unknown): ts.TypeNode {
|
||
if (typeof value === "string") {
|
||
// workaround for UTF-8: https://github.com/microsoft/TypeScript/issues/36174
|
||
return ts.factory.createIdentifier(JSON.stringify(value)) as unknown as ts.TypeNode;
|
||
}
|
||
if (typeof value === "number") {
|
||
const literal =
|
||
value < 0
|
||
? ts.factory.createPrefixUnaryExpression(
|
||
ts.SyntaxKind.MinusToken,
|
||
ts.factory.createNumericLiteral(Math.abs(value)),
|
||
)
|
||
: ts.factory.createNumericLiteral(value);
|
||
return ts.factory.createLiteralTypeNode(literal);
|
||
}
|
||
if (typeof value === "boolean") {
|
||
return value === true ? TRUE : FALSE;
|
||
}
|
||
if (value === null) {
|
||
return NULL;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
if (value.length === 0) {
|
||
return ts.factory.createArrayTypeNode(NEVER);
|
||
}
|
||
return ts.factory.createTupleTypeNode(value.map((v: unknown) => tsLiteral(v)));
|
||
}
|
||
if (typeof value === "object") {
|
||
const keys: ts.TypeElement[] = [];
|
||
for (const [k, v] of Object.entries(value)) {
|
||
keys.push(
|
||
ts.factory.createPropertySignature(
|
||
/* modifiers */ undefined,
|
||
/* name */ tsPropertyIndex(k),
|
||
/* questionToken */ undefined,
|
||
/* type */ tsLiteral(v),
|
||
),
|
||
);
|
||
}
|
||
return keys.length ? ts.factory.createTypeLiteralNode(keys) : tsRecord(STRING, NEVER);
|
||
}
|
||
return UNKNOWN;
|
||
}
|
||
|
||
/** Modifiers (readonly) */
|
||
export function tsModifiers(modifiers: { readonly?: boolean; export?: boolean }): ts.Modifier[] {
|
||
const typeMods: ts.Modifier[] = [];
|
||
if (modifiers.export) {
|
||
typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ExportKeyword));
|
||
}
|
||
if (modifiers.readonly) {
|
||
typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword));
|
||
}
|
||
return typeMods;
|
||
}
|
||
|
||
/** Create a T | null union */
|
||
export function tsNullable(types: ts.TypeNode[]): ts.TypeNode {
|
||
return ts.factory.createUnionTypeNode([...types, NULL]);
|
||
}
|
||
|
||
/** Create a TS Omit<X, Y> type */
|
||
export function tsOmit(type: ts.TypeNode, keys: string[]): ts.TypeNode {
|
||
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Omit"), [
|
||
type,
|
||
ts.factory.createUnionTypeNode(keys.map((k) => tsLiteral(k))),
|
||
]);
|
||
}
|
||
|
||
/** Create a TS Record<X, Y> type */
|
||
export function tsRecord(key: ts.TypeNode, value: ts.TypeNode) {
|
||
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Record"), [key, value]);
|
||
}
|
||
|
||
/** Create a valid property index */
|
||
export function tsPropertyIndex(index: string | number) {
|
||
if (
|
||
(typeof index === "number" && !(index < 0)) ||
|
||
(typeof index === "string" && String(Number(index)) === index && index[0] !== "-")
|
||
) {
|
||
return ts.factory.createNumericLiteral(index);
|
||
}
|
||
return typeof index === "string" && JS_PROPERTY_INDEX_RE.test(index)
|
||
? ts.factory.createIdentifier(index)
|
||
: ts.factory.createStringLiteral(String(index));
|
||
}
|
||
|
||
/** Create a union type */
|
||
export function tsUnion(types: ts.TypeNode[]): ts.TypeNode {
|
||
if (types.length === 0) {
|
||
return NEVER;
|
||
}
|
||
if (types.length === 1) {
|
||
return types[0];
|
||
}
|
||
return ts.factory.createUnionTypeNode(tsDedupe(types));
|
||
}
|
||
|
||
/** Create a WithRequired<X, Y> type */
|
||
export function tsWithRequired(
|
||
type: ts.TypeNode,
|
||
keys: string[],
|
||
injectFooter: ts.Node[], // needed to inject type helper if used
|
||
): ts.TypeNode {
|
||
if (keys.length === 0) {
|
||
return type;
|
||
}
|
||
|
||
// inject helper, if needed
|
||
if (!injectFooter.some((node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "WithRequired")) {
|
||
const helper = stringToAST("type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };")[0] as any;
|
||
injectFooter.push(helper);
|
||
}
|
||
|
||
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("WithRequired"), [
|
||
type,
|
||
tsUnion(keys.map((k) => tsLiteral(k))),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Enhanced ReadonlyArray.
|
||
* eg: type Foo = ReadonlyArray<T>; type Bar = ReadonlyArray<T[]>
|
||
* Foo and Bar are both of type `readonly T[]`
|
||
*/
|
||
export function tsReadonlyArray(type: ts.TypeNode, injectFooter?: ts.Node[]): ts.TypeNode {
|
||
if (
|
||
injectFooter &&
|
||
!injectFooter.some((node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "ReadonlyArray")
|
||
) {
|
||
const helper = stringToAST(
|
||
"type ReadonlyArray<T> = [Exclude<T, undefined>] extends [unknown[]] ? Readonly<Exclude<T, undefined>> : Readonly<Exclude<T, undefined>[]>;",
|
||
)[0] as any;
|
||
injectFooter.push(helper);
|
||
}
|
||
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("ReadonlyArray"), [type]);
|
||
}
|