Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions factory/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { CompilerOptions } from "typescript";
import ts from "typescript";
import type { CompletedConfig, Config } from "../src/Config.js";
import { BuildError } from "../src/Error/Errors.js";
import fs from "node:fs";
// @ts-ignore - glob package doesn't have types but works fine
import { sync as globSync } from "glob";
Comment thread
arthurfiorette marked this conversation as resolved.
Outdated

function loadTsConfigFile(configFile: string) {
const raw = ts.sys.readFile(configFile);
Expand Down Expand Up @@ -68,7 +69,7 @@ function getTsConfig(config: Config) {

export function createProgram(config: CompletedConfig): ts.Program {
const rootNamesFromPath = config.path
? fs.globSync(normalize(path.resolve(config.path))).map((rootName) => normalize(rootName))
? globSync(normalize(path.resolve(config.path))).map((rootName: string) => normalize(rootName))
: [];
const tsconfig = getTsConfig(config);
const rootNames = rootNamesFromPath.length ? rootNamesFromPath : tsconfig.fileNames;
Expand Down
3 changes: 2 additions & 1 deletion src/NodeParser/AnnotatedNodeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { removeUndefined } from "../Utils/removeUndefined.js";
import { DefinitionType } from "../Type/DefinitionType.js";
import { UnionType } from "../Type/UnionType.js";
import { AnyType } from "../Type/AnyType.js";
import { TYPESCRIPT_LIB_FILE_PATTERN } from "../Utils/isTypeScriptLibFile.js";

export class AnnotatedNodeParser implements SubNodeParser {
public constructor(
Expand All @@ -35,7 +36,7 @@ export class AnnotatedNodeParser implements SubNodeParser {

// Don't return annotations for lib types such as Exclude.
// Sourceless nodes may not have a fileName, just ignore them.
if (node.getSourceFile()?.fileName.match(/[/\\]typescript[/\\]lib[/\\]lib\.[^/\\]+\.d\.ts$/i)) {
if (TYPESCRIPT_LIB_FILE_PATTERN.test(node.getSourceFile()?.fileName ?? "")) {
Comment thread
arthurfiorette marked this conversation as resolved.
Outdated
let specialCase = false;

// Special case for Exclude<T, U>: use the annotation of T.
Expand Down
34 changes: 31 additions & 3 deletions src/NodeParser/ExpressionWithTypeArgumentsNodeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,45 @@ export class ExpressionWithTypeArgumentsNodeParser implements SubNodeParser {
return node.kind === ts.SyntaxKind.ExpressionWithTypeArguments;
}
public createType(node: ts.ExpressionWithTypeArguments, context: Context): BaseType {
const typeSymbol = this.typeChecker.getSymbolAtLocation(node.expression)!;
const typeSymbol = this.typeChecker.getSymbolAtLocation(node.expression);
if (!typeSymbol) {
const sourceFile = node.getSourceFile();
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart());
throw new Error(
Comment thread
arthurfiorette marked this conversation as resolved.
Outdated
`Cannot resolve symbol for expression: ${node.expression.getText()} ` +
`at ${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
);
}

if (typeSymbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = this.typeChecker.getAliasedSymbol(typeSymbol);
const declaration = aliasedSymbol.declarations?.[0];
if (!declaration) {
const sourceFile = node.getSourceFile();
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart());
throw new Error(
`No declaration found for aliased symbol: ${aliasedSymbol.name} ` +
`at ${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
);
}

return this.childNodeParser.createType(
aliasedSymbol.declarations![0],
declaration,
this.createSubContext(node, context),
);
} else if (typeSymbol.flags & ts.SymbolFlags.TypeParameter) {
return context.getArgument(typeSymbol.name);
} else {
return this.childNodeParser.createType(typeSymbol.declarations![0], this.createSubContext(node, context));
const declaration = typeSymbol.declarations?.[0];
if (!declaration) {
const sourceFile = node.getSourceFile();
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart());
throw new Error(
`No declaration found for symbol: ${typeSymbol.name} ` +
`at ${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
);
}
return this.childNodeParser.createType(declaration, this.createSubContext(node, context));
}
}

Expand Down
21 changes: 20 additions & 1 deletion src/NodeParser/InterfaceAndClassNodeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { ReferenceType } from "../Type/ReferenceType.js";
import { isNodeHidden } from "../Utils/isHidden.js";
import { isPublic, isStatic } from "../Utils/modifiers.js";
import { getKey } from "../Utils/nodeKey.js";
import { isTypeScriptLibFile } from "../Utils/isTypeScriptLibFile.js";

export class InterfaceAndClassNodeParser implements SubNodeParser {
public constructor(
Expand Down Expand Up @@ -96,7 +97,25 @@ export class InterfaceAndClassNodeParser implements SubNodeParser {
return node.heritageClauses.reduce(
(result: BaseType[], baseType) => [
...result,
...baseType.types.map((expression) => this.childNodeParser.createType(expression, context)),
...baseType.types.map((expression) => {
// Skip processing of TypeScript lib utility types in heritage clauses
// to avoid infinite recursion with recursive types
const typeSymbol = this.typeChecker.getSymbolAtLocation(expression.expression);
if (typeSymbol && typeSymbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = this.typeChecker.getAliasedSymbol(typeSymbol);
// Check if any declaration is from a TypeScript lib file
// Lib utility types (Omit, Pick, etc.) should be skipped to prevent
// following into their internal mapped type implementations
const isLibType = aliasedSymbol.declarations?.some((decl) =>
isTypeScriptLibFile(decl.getSourceFile())
);
if (isLibType) {
// This is a lib utility type - skip it
return null;
}
}
return this.childNodeParser.createType(expression, context);
}).filter((type): type is BaseType => type !== null),
],
[],
);
Expand Down
23 changes: 23 additions & 0 deletions src/Utils/isTypeScriptLibFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type ts from "typescript";

/**
* Regular expression pattern for detecting TypeScript lib files.
* Matches file paths like: /path/to/typescript/lib/lib.es5.d.ts
*/
export const TYPESCRIPT_LIB_FILE_PATTERN = /[/\\]typescript[/\\]lib[/\\]lib\.[^/\\]+\.d\.ts$/i;

/**
* Checks if a source file is part of the TypeScript standard library.
* This is used to identify utility types (like Omit, Pick, Exclude, etc.)
* that should be treated specially to avoid infinite recursion issues.
*
* @param sourceFile The source file to check
* @returns true if the file is a TypeScript lib file, false otherwise
*/
export function isTypeScriptLibFile(sourceFile: ts.SourceFile | undefined): boolean {
if (!sourceFile) {
return false;
}

return TYPESCRIPT_LIB_FILE_PATTERN.test(sourceFile.fileName);
}
4 changes: 4 additions & 0 deletions test/valid-data/type-omit-recursive/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { assertValidSchema } from "../../utils";
import { test } from "node:test";

test("valid-data - type-omit-recursive", assertValidSchema("type-omit-recursive", "IFormProps"));
16 changes: 16 additions & 0 deletions test/valid-data/type-omit-recursive/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Test case for Omit with recursive interfaces
// This ensures that when an interface extends Omit<RecursiveType, 'property'>,
// the schema generator correctly handles the utility type without infinite recursion

interface IItems {
items?: IItems;
value: string;
}

// IFormProps extends Omit<IItems, 'items'> should only have the 'value' property
// The 'items' property is correctly omitted
export interface IFormProps extends Omit<IItems, 'items'> {
}

// Note: More complex cases with conditional types and infer (like ArrayElement<T>)
Comment thread
arthurfiorette marked this conversation as resolved.
Outdated
// may still cause issues and are tracked separately
18 changes: 18 additions & 0 deletions test/valid-data/type-omit-recursive/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$ref": "#/definitions/IFormProps",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"IFormProps": {
"additionalProperties": false,
"properties": {
"value": {
"type": "string"
}
},
"required": [
"value"
],
"type": "object"
}
}
}