Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
7 changes: 6 additions & 1 deletion factory/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ 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-expect-error - glob package doesn't have types but works fine
import { sync as globSyncFallback } from "glob";

// Use native fs.globSync if available (Node >= 22), otherwise use glob package
const globSync = fs.globSync || globSyncFallback;
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 +73,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) => 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 { isTypeScriptLibFile } 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 (isTypeScriptLibFile(node.getSourceFile())) {
let specialCase = false;

// Special case for Exclude<T, U>: use the annotation of T.
Expand Down
23 changes: 17 additions & 6 deletions src/NodeParser/ExpressionWithTypeArgumentsNodeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { NodeParser } from "../NodeParser.js";
import { Context } from "../NodeParser.js";
import type { SubNodeParser } from "../SubNodeParser.js";
import type { BaseType } from "../Type/BaseType.js";
import { LogicError } from "../Error/Errors.js";

export class ExpressionWithTypeArgumentsNodeParser implements SubNodeParser {
public constructor(
Expand All @@ -14,17 +15,27 @@ 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) {
throw new LogicError(node, `Cannot resolve symbol for expression: ${node.expression.getText()}`);
}

if (typeSymbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = this.typeChecker.getAliasedSymbol(typeSymbol);
return this.childNodeParser.createType(
aliasedSymbol.declarations![0],
this.createSubContext(node, context),
);
const declaration = aliasedSymbol.declarations?.[0];
if (!declaration) {
throw new LogicError(node, `No declaration found for aliased symbol: ${aliasedSymbol.name}`);
}

return this.childNodeParser.createType(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) {
throw new LogicError(node, `No declaration found for symbol: ${typeSymbol.name}`);
}
return this.childNodeParser.createType(declaration, this.createSubContext(node, context));
}
}

Expand Down
23 changes: 22 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,27 @@ export class InterfaceAndClassNodeParser implements SubNodeParser {
return node.heritageClauses.reduce(
(result: BaseType[], baseType) => [
...result,
...baseType.types.map((expression) => this.childNodeParser.createType(expression, context)),
...baseType.types

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file has rather involved changes. Are they really required since the error seems to be a regression (and the previous code worked).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core change is minimal - it adds a filter to skip TypeScript lib utility types in heritage clauses to prevent infinite recursion. The involved formatting is due to:

  1. Converting simple .map() to .map().filter() chain
  2. Adding the lib type detection logic inside the map

The alternative would be improving circular reference detection to handle the recursive type + Omit case, which would be a much larger change. This approach prevents the parser from following into the internal mapped type implementations of lib utility types (Omit, Pick, etc.) which was causing the recursion.

.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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's test that omit and pick if not recursive work well even after this change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing tests for non-recursive Omit and Pick already exist and pass:

  • test/valid-data/type-conditional-omit - tests Omit<Test, "b" | "d">
  • test/valid-data/type-mapped-pick-union-alias - tests Pick<SomeInterface, ABCD | E>

These verify that the fix doesn't break non-recursive usage of these utility types.

// 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);
}
7 changes: 7 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,7 @@
import { assertValidSchema } from "../../utils";
import { test } from "node:test";

test(
"valid-data - type-omit-recursive",
assertValidSchema("type-omit-recursive", ["IFormProps", "SimpleComplexItem", "PickedItem"]),
);
33 changes: 33 additions & 0 deletions test/valid-data/type-omit-recursive/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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

// Simple recursive interface case
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'> {
}

// Complex case with multiple properties
interface ComplexItem {
id: string;
nested?: ComplexItem;
data: number;
optional?: string;
}

// Omit multiple properties
export interface SimpleComplexItem extends Omit<ComplexItem, 'nested' | 'optional'> {
}

// Pick variant (also a utility type)
export interface PickedItem extends Pick<IItems, 'value'> {
}

// Note: Cases with conditional types and infer (like ArrayElement<T>) are known to still
// cause issues and are tracked separately as they require different handling
45 changes: 45 additions & 0 deletions test/valid-data/type-omit-recursive/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"IFormProps": {
"additionalProperties": false,
"properties": {
"value": {
"type": "string"
}
},
"required": [
"value"
],
"type": "object"
},
"PickedItem": {
"additionalProperties": false,
"properties": {
"value": {
"type": "string"
}
},
"required": [
"value"
],
"type": "object"
},
"SimpleComplexItem": {
"additionalProperties": false,
"properties": {
"data": {
"type": "number"
},
"id": {
"type": "string"
}
},
"required": [
"data",
"id"
],
"type": "object"
}
}
}