From 98b993bb62e9b4e1cb4763cdfad6b28bccb56a7d Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Sun, 23 Nov 2025 12:14:14 -0800 Subject: [PATCH 01/16] fail --- test/valid-data-type.test.ts | 1 + test/valid-data/binary-expression/main.ts | 3 +++ test/valid-data/binary-expression/schema.json | 16 ++++++++++++++++ 3 files changed, 20 insertions(+) create mode 100644 test/valid-data/binary-expression/main.ts create mode 100644 test/valid-data/binary-expression/schema.json diff --git a/test/valid-data-type.test.ts b/test/valid-data-type.test.ts index 519b48864..4a1bbbd4a 100644 --- a/test/valid-data-type.test.ts +++ b/test/valid-data-type.test.ts @@ -9,6 +9,7 @@ describe("valid-data-type", () => { it("type-aliases-object", assertValidSchema("type-aliases-object", "MyAlias")); it("type-aliases-mixed", assertValidSchema("type-aliases-mixed", "MyObject")); it("type-aliases-union", assertValidSchema("type-aliases-union", "MyUnion")); + it("binary-expression", assertValidSchema("binary-expression", "MyObject")); it("type-aliases-anonymous", assertValidSchema("type-aliases-anonymous", "MyObject")); it("type-aliases-local-namespace", assertValidSchema("type-aliases-local-namespace", "MyObject")); it("type-aliases-recursive-anonymous", assertValidSchema("type-aliases-recursive-anonymous", "MyAlias")); diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts new file mode 100644 index 000000000..637aa9532 --- /dev/null +++ b/test/valid-data/binary-expression/main.ts @@ -0,0 +1,3 @@ +export const MyObject = { + foo: 60 * 5, +} as const satisfies { foo: number }; diff --git a/test/valid-data/binary-expression/schema.json b/test/valid-data/binary-expression/schema.json new file mode 100644 index 000000000..60c1d0d26 --- /dev/null +++ b/test/valid-data/binary-expression/schema.json @@ -0,0 +1,16 @@ +{ + "$ref": "#/definitions/MyObject", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "MyObject": { + "additionalProperties": false, + "properties": { + "foo": { "type": "number" } + }, + "required": [ + "foo" + ], + "type": "object" + } + } +} From 6b835e5c11829f7e37e480fea035130e27064652 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Mon, 24 Nov 2025 19:49:05 -0800 Subject: [PATCH 02/16] try again --- src/NodeParser/BinaryExpressionNodeParser.ts | 14 ++++++++++++++ test/valid-data/binary-expression/main.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/NodeParser/BinaryExpressionNodeParser.ts diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts new file mode 100644 index 000000000..1418fd3ee --- /dev/null +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -0,0 +1,14 @@ +import ts from "typescript"; +import type { Context } from "../NodeParser.js"; +import type { SubNodeParser } from "../SubNodeParser.js"; +import type { BaseType } from "../Type/BaseType.js"; +import { NumberType } from "../Type/NumberType.js"; + +export class BinaryExpressionNodeParser implements SubNodeParser { + public supportsNode(node: ts.Node): boolean { + return node.kind === ts.SyntaxKind.BinaryExpression; + } + public createType(node: ts.BinaryExpression, context: Context): BaseType { + return new NumberType(); + } +} diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index 637aa9532..1a19bfd77 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -1,3 +1,5 @@ -export const MyObject = { +const foo = { foo: 60 * 5, } as const satisfies { foo: number }; + +export type MyObject = typeof foo; From 893da25d40a86d1e944cdfedb4b23a607e1f9509 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Mon, 24 Nov 2025 19:52:47 -0800 Subject: [PATCH 03/16] binary expression --- factory/parser.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/factory/parser.ts b/factory/parser.ts index f682b2f24..10d6c4981 100644 --- a/factory/parser.ts +++ b/factory/parser.ts @@ -12,6 +12,7 @@ import { AnyTypeNodeParser } from "../src/NodeParser/AnyTypeNodeParser.js"; import { ArrayLiteralExpressionNodeParser } from "../src/NodeParser/ArrayLiteralExpressionNodeParser.js"; import { ArrayNodeParser } from "../src/NodeParser/ArrayNodeParser.js"; import { AsExpressionNodeParser } from "../src/NodeParser/AsExpressionNodeParser.js"; +import { BinaryExpressionNodeParser } from "../src/NodeParser/BinaryExpressionNodeParser.js"; import { BooleanLiteralNodeParser } from "../src/NodeParser/BooleanLiteralNodeParser.js"; import { BooleanTypeNodeParser } from "../src/NodeParser/BooleanTypeNodeParser.js"; import { CallExpressionParser } from "../src/NodeParser/CallExpressionParser.js"; @@ -114,6 +115,7 @@ export function createParser(program: ts.Program, config: CompletedConfig, augme .addNodeParser(new NeverTypeNodeParser()) .addNodeParser(new ObjectTypeNodeParser()) .addNodeParser(new AsExpressionNodeParser(chainNodeParser)) + .addNodeParser(new BinaryExpressionNodeParser()) .addNodeParser(new SatisfiesNodeParser(chainNodeParser)) .addNodeParser(withJsDoc(new ParameterParser(chainNodeParser))) .addNodeParser(new StringLiteralNodeParser()) From 707fd30f676cd836a6e90496b6dc6ae258e44021 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Mon, 24 Nov 2025 19:56:30 -0800 Subject: [PATCH 04/16] rm satisfies --- test/valid-data/binary-expression/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index 1a19bfd77..bacaab056 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -1,5 +1,5 @@ const foo = { foo: 60 * 5, -} as const satisfies { foo: number }; +} as const; export type MyObject = typeof foo; From dcc4b2d043bc5d7970de94f0baf07e062831dfc2 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Mon, 24 Nov 2025 20:02:07 -0800 Subject: [PATCH 05/16] extra comment --- src/NodeParser/BinaryExpressionNodeParser.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index 1418fd3ee..5803ba80d 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -9,6 +9,8 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return node.kind === ts.SyntaxKind.BinaryExpression; } public createType(node: ts.BinaryExpression, context: Context): BaseType { + // For the purposes of types, assume that binary expressions always + // evaluate to a number. return new NumberType(); } } From 7f85f4de821da96b4e2207dcccd22c4a09fff2f4 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Tue, 25 Nov 2025 07:18:21 -0800 Subject: [PATCH 06/16] switch to three nums --- test/valid-data/binary-expression/main.ts | 6 +++++- test/valid-data/binary-expression/schema.json | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index bacaab056..09974238a 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -1,5 +1,9 @@ const foo = { - foo: 60 * 5, + numbers: 60 * 5, + threeNumbers: 60 * 5 + 1, + strings: "a" + "b", + booleans: true && false, + any: 1 + ("test" as any), } as const; export type MyObject = typeof foo; diff --git a/test/valid-data/binary-expression/schema.json b/test/valid-data/binary-expression/schema.json index 60c1d0d26..54f1b550a 100644 --- a/test/valid-data/binary-expression/schema.json +++ b/test/valid-data/binary-expression/schema.json @@ -5,10 +5,18 @@ "MyObject": { "additionalProperties": false, "properties": { - "foo": { "type": "number" } + "numbers": { "type": "number" }, + "threeNumbers": { "type": "number" }, + "strings": { "type": "string" }, + "booleans": { "type": "boolean" }, + "any": {} }, "required": [ - "foo" + "numbers", + "threeNumbers", + "strings", + "booleans", + "any" ], "type": "object" } From 95bcb3bf88ca610dae90fe3d2e80eb565b1d4c5d Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Tue, 25 Nov 2025 07:40:13 -0800 Subject: [PATCH 07/16] skeleton, test runs --- factory/parser.ts | 2 +- src/NodeParser/BinaryExpressionNodeParser.ts | 79 +++++++++++++++++++- test/valid-data/binary-expression/main.ts | 3 +- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/factory/parser.ts b/factory/parser.ts index 10d6c4981..40b3ec9ce 100644 --- a/factory/parser.ts +++ b/factory/parser.ts @@ -115,7 +115,7 @@ export function createParser(program: ts.Program, config: CompletedConfig, augme .addNodeParser(new NeverTypeNodeParser()) .addNodeParser(new ObjectTypeNodeParser()) .addNodeParser(new AsExpressionNodeParser(chainNodeParser)) - .addNodeParser(new BinaryExpressionNodeParser()) + .addNodeParser(new BinaryExpressionNodeParser(typeChecker)) .addNodeParser(new SatisfiesNodeParser(chainNodeParser)) .addNodeParser(withJsDoc(new ParameterParser(chainNodeParser))) .addNodeParser(new StringLiteralNodeParser()) diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index 5803ba80d..d79a2fe5f 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -1,16 +1,93 @@ import ts from "typescript"; import type { Context } from "../NodeParser.js"; import type { SubNodeParser } from "../SubNodeParser.js"; +import { AnyType } from "../Type/AnyType.js"; import type { BaseType } from "../Type/BaseType.js"; import { NumberType } from "../Type/NumberType.js"; +import { StringType } from "../Type/StringType.js"; export class BinaryExpressionNodeParser implements SubNodeParser { + public constructor(protected typeChecker: ts.TypeChecker) {} + public supportsNode(node: ts.Node): boolean { return node.kind === ts.SyntaxKind.BinaryExpression; } + public createType(node: ts.BinaryExpression, context: Context): BaseType { // For the purposes of types, assume that binary expressions always // evaluate to a number. - return new NumberType(); + const leftType = this.typeChecker.getTypeAtLocation(node.left); + const rightType = this.typeChecker.getTypeAtLocation(node.right); + + if (this.isAny(leftType) || this.isAny(rightType)) { + return new AnyType(); + } + + // 1) If either side is string-like → 'string' + if (this.isStringLike(leftType) || this.isStringLike(rightType)) { + return new StringType(); + } + + // 2) If both sides are definitely number-like → 'number' + if (this.isDefinitelyNumberLike(leftType) && this.isDefinitelyNumberLike(rightType)) { + return new NumberType(); + } + + // 3) Anything else (objects, any, unknown, weird unions, etc.) → + // 'string' because at runtime + will usually go through ToPrimitive and end + // up in the "string concatenation" branch when non-numeric stuff is + // involved. + return new StringType(); + } + + private isAny(type: ts.Type): boolean { + return (type.flags & ts.TypeFlags.Any) !== 0; + } + + private isStringLike(inType: ts.Type): boolean { + // Use apparent type to collapse things like literal unions, etc. + const type = this.typeChecker.getApparentType(inType); + + // Union? Any member being string-like is enough. + if (type.isUnion()) { + return type.types.some((t) => this.isStringLike(t)); + } + + const f = type.flags; + + // String primitives + string literals + template literals + if (f & ts.TypeFlags.StringLike) { + return true; + } + + // Optionally treat String object type as string-like: + const symbol = type.getSymbol(); + if (symbol && symbol.getName() === "String") { + return true; + } + + return false; + } + + private isDefinitelyNumberLike(inType: ts.Type): boolean { + // Again, use apparent type for unions/intersections + const type = this.typeChecker.getApparentType(inType); + + if (type.isUnion()) { + // Must be number-like for *all* members to be "definitely number-like" + return type.types.every((t) => this.isDefinitelyNumberLike(t)); + } + + const f = type.flags; + + // Number, number literal, enums, bigint etc. + // If you don't want bigint, drop BigIntLike. + const numericFlags = ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike | ts.TypeFlags.EnumLike; + + if (f & numericFlags) { + return true; + } + + return false; } } diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index 09974238a..d562d9290 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -1,9 +1,10 @@ const foo = { numbers: 60 * 5, - threeNumbers: 60 * 5 + 1, strings: "a" + "b", booleans: true && false, any: 1 + ("test" as any), + threeNumbers: 60 * 5 + 1, + mixedStringAndNumber: 60 * 5 + " minutes", } as const; export type MyObject = typeof foo; From cf1355184878a3ca125f288d6ebd99bdb1db954a Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Tue, 25 Nov 2025 08:16:34 -0800 Subject: [PATCH 08/16] tests passing --- package.json | 1 + src/NodeParser/BinaryExpressionNodeParser.ts | 80 +++++++++++++++++-- test/valid-data/binary-expression/main.ts | 2 +- test/valid-data/binary-expression/schema.json | 6 +- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index c748d1212..18b6fdb0b 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "release": "npm run build && auto shipit", "run": "tsx ts-json-schema-generator.ts", "test": "jest test/ --verbose", + "test:debug": "node --inspect-brk node_modules/.bin/jest test/ --verbose --runInBand", "test:coverage": "npm run jest -- test/ --collectCoverage=true", "test:fast": "cross-env FAST_TEST=1 jest test/ --verbose", "test:update": "cross-env UPDATE_SCHEMA=true npm run test:fast", diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index d79a2fe5f..08f21c6ad 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -5,6 +5,7 @@ import { AnyType } from "../Type/AnyType.js"; import type { BaseType } from "../Type/BaseType.js"; import { NumberType } from "../Type/NumberType.js"; import { StringType } from "../Type/StringType.js"; +import { BooleanType } from "../Type/BooleanType.js"; export class BinaryExpressionNodeParser implements SubNodeParser { public constructor(protected typeChecker: ts.TypeChecker) {} @@ -28,11 +29,20 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return new StringType(); } + debugger; + // 2) If both sides are definitely number-like → 'number' if (this.isDefinitelyNumberLike(leftType) && this.isDefinitelyNumberLike(rightType)) { + console.log(`XXX in numbertype`); return new NumberType(); } + if (this.isBoolean(leftType) && this.isBoolean(rightType)) { + return new BooleanType(); + } + + console.log(`XXX at fallthrough`); + // 3) Anything else (objects, any, unknown, weird unions, etc.) → // 'string' because at runtime + will usually go through ToPrimitive and end // up in the "string concatenation" branch when non-numeric stuff is @@ -53,10 +63,8 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return type.types.some((t) => this.isStringLike(t)); } - const f = type.flags; - // String primitives + string literals + template literals - if (f & ts.TypeFlags.StringLike) { + if (type.flags & ts.TypeFlags.StringLike) { return true; } @@ -69,25 +77,83 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return false; } + private isBoolean(inType: ts.Type): boolean { + const type = this.typeChecker.getApparentType(inType); + + // Union? Any member being string-like is enough. + if (type.isUnion()) { + return type.types.some((t) => this.isStringLike(t)); + } + + // String primitives + string literals + template literals + if (type.flags & ts.TypeFlags.BooleanLike) { + return true; + } + + // Optionally treat String object type as string-like: + const symbol = type.getSymbol(); + if (symbol && symbol.getName() === "Boolean") { + return true; + } + + return false; + } + private isDefinitelyNumberLike(inType: ts.Type): boolean { + debugger; // Again, use apparent type for unions/intersections const type = this.typeChecker.getApparentType(inType); + const typeStr = this.typeChecker.typeToString(type); + if (typeStr === "Number") { + return true; + } + // console.log(`XXX type`, type); + console.log( + `XXX this.typeChecker.typeToString(type), + `, + this.typeChecker.typeToString(type), + ); + if (type.isUnion()) { + console.log(`XXX in in union`); // Must be number-like for *all* members to be "definitely number-like" return type.types.every((t) => this.isDefinitelyNumberLike(t)); } - const f = type.flags; + console.log(`XXX getTypeFlagNames(type)`, getTypeFlagNames(type)); // Number, number literal, enums, bigint etc. - // If you don't want bigint, drop BigIntLike. - const numericFlags = ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike | ts.TypeFlags.EnumLike; + const numericFlags = + ts.TypeFlags.Number | + ts.TypeFlags.NumberLiteral | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.EnumLike; - if (f & numericFlags) { + console.log(`XXX type.flags`, type.flags, numericFlags); + + if (type.flags & numericFlags) { return true; } return false; } } + +function getTypeFlagNames(type: ts.Type): string[] { + const flags = type.flags; + const names: string[] = []; + + for (const key of Object.keys(ts.TypeFlags)) { + // filter out the numeric reverse-mapping entries + if (!Number.isNaN(Number(key))) continue; + + const flagValue = (ts.TypeFlags as any)[key] as number; + if ((flags & flagValue) !== 0) { + names.push(key); + } + } + + return names; +} diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index d562d9290..070c547dd 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -4,7 +4,7 @@ const foo = { booleans: true && false, any: 1 + ("test" as any), threeNumbers: 60 * 5 + 1, - mixedStringAndNumber: 60 * 5 + " minutes", + mixedStringAndNumbers: 60 * 5 + " minutes", } as const; export type MyObject = typeof foo; diff --git a/test/valid-data/binary-expression/schema.json b/test/valid-data/binary-expression/schema.json index 54f1b550a..208bcbac8 100644 --- a/test/valid-data/binary-expression/schema.json +++ b/test/valid-data/binary-expression/schema.json @@ -8,15 +8,17 @@ "numbers": { "type": "number" }, "threeNumbers": { "type": "number" }, "strings": { "type": "string" }, + "mixedStringAndNumbers": { "type": "string" }, "booleans": { "type": "boolean" }, "any": {} }, "required": [ "numbers", - "threeNumbers", "strings", "booleans", - "any" + "any", + "threeNumbers", + "mixedStringAndNumbers" ], "type": "object" } From 0b209a0637ec67c6329422e201c0b5153c7547e6 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Tue, 25 Nov 2025 08:19:37 -0800 Subject: [PATCH 09/16] clean up --- src/NodeParser/BinaryExpressionNodeParser.ts | 36 +------------------- test/utils.ts | 19 ++++++++++- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index 08f21c6ad..380680441 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -29,11 +29,8 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return new StringType(); } - debugger; - // 2) If both sides are definitely number-like → 'number' if (this.isDefinitelyNumberLike(leftType) && this.isDefinitelyNumberLike(rightType)) { - console.log(`XXX in numbertype`); return new NumberType(); } @@ -41,8 +38,6 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return new BooleanType(); } - console.log(`XXX at fallthrough`); - // 3) Anything else (objects, any, unknown, weird unions, etc.) → // 'string' because at runtime + will usually go through ToPrimitive and end // up in the "string concatenation" branch when non-numeric stuff is @@ -100,29 +95,19 @@ export class BinaryExpressionNodeParser implements SubNodeParser { } private isDefinitelyNumberLike(inType: ts.Type): boolean { - debugger; - // Again, use apparent type for unions/intersections + // Use apparent type for unions/intersections const type = this.typeChecker.getApparentType(inType); const typeStr = this.typeChecker.typeToString(type); if (typeStr === "Number") { return true; } - // console.log(`XXX type`, type); - console.log( - `XXX this.typeChecker.typeToString(type), - `, - this.typeChecker.typeToString(type), - ); if (type.isUnion()) { - console.log(`XXX in in union`); // Must be number-like for *all* members to be "definitely number-like" return type.types.every((t) => this.isDefinitelyNumberLike(t)); } - console.log(`XXX getTypeFlagNames(type)`, getTypeFlagNames(type)); - // Number, number literal, enums, bigint etc. const numericFlags = ts.TypeFlags.Number | @@ -131,8 +116,6 @@ export class BinaryExpressionNodeParser implements SubNodeParser { ts.TypeFlags.BigIntLike | ts.TypeFlags.EnumLike; - console.log(`XXX type.flags`, type.flags, numericFlags); - if (type.flags & numericFlags) { return true; } @@ -140,20 +123,3 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return false; } } - -function getTypeFlagNames(type: ts.Type): string[] { - const flags = type.flags; - const names: string[] = []; - - for (const key of Object.keys(ts.TypeFlags)) { - // filter out the numeric reverse-mapping entries - if (!Number.isNaN(Number(key))) continue; - - const flagValue = (ts.TypeFlags as any)[key] as number; - if ((flags & flagValue) !== 0) { - names.push(key); - } - } - - return names; -} diff --git a/test/utils.ts b/test/utils.ts index 510ce9dc9..112d4ff39 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -4,7 +4,7 @@ import addFormats from "ajv-formats"; import { readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; import stringify from "safe-stable-stringify"; -import type ts from "typescript"; +import ts from "typescript"; import { createFormatter } from "../factory/formatter"; import { createParser } from "../factory/parser"; import { createProgram } from "../factory/program"; @@ -109,3 +109,20 @@ export function assertValidSchema( } }; } + +export function getTypeFlagNames(type: ts.Type): string[] { + const flags = type.flags; + const names: string[] = []; + + for (const key of Object.keys(ts.TypeFlags)) { + // filter out the numeric reverse-mapping entries + if (!Number.isNaN(Number(key))) continue; + + const flagValue = ts.TypeFlags[key as keyof ts.TypeFlags] as number; + if ((flags & flagValue) !== 0) { + names.push(key); + } + } + + return names; +} From daf1162c78283e545b18b3bf652f62b8b9b480e9 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Tue, 25 Nov 2025 08:32:05 -0800 Subject: [PATCH 10/16] union types passing --- test/valid-data/binary-expression/main.ts | 9 +++++++++ test/valid-data/binary-expression/schema.json | 10 +++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index 070c547dd..ddf9db4cb 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -1,3 +1,7 @@ +type StringUnion = "a" | "b"; +type NumberUnion = 10 | 20; +type MixedUnion = "c" | 30; + const foo = { numbers: 60 * 5, strings: "a" + "b", @@ -5,6 +9,11 @@ const foo = { any: 1 + ("test" as any), threeNumbers: 60 * 5 + 1, mixedStringAndNumbers: 60 * 5 + " minutes", + bigintType: BigInt(123), + + stringUnion: ("a" as StringUnion) + ("b" as StringUnion), + numberUnion: (10 as NumberUnion) + (20 as NumberUnion), + mixedUnion: (30 as MixedUnion) + " is a number", } as const; export type MyObject = typeof foo; diff --git a/test/valid-data/binary-expression/schema.json b/test/valid-data/binary-expression/schema.json index 208bcbac8..addac7932 100644 --- a/test/valid-data/binary-expression/schema.json +++ b/test/valid-data/binary-expression/schema.json @@ -10,6 +10,10 @@ "strings": { "type": "string" }, "mixedStringAndNumbers": { "type": "string" }, "booleans": { "type": "boolean" }, + "bigintType": { "type": "number" }, + "numberUnion": { "type": "number" }, + "stringUnion": { "type": "string" }, + "mixedUnion": { "type": "string" }, "any": {} }, "required": [ @@ -18,7 +22,11 @@ "booleans", "any", "threeNumbers", - "mixedStringAndNumbers" + "mixedStringAndNumbers", + "bigintType", + "stringUnion", + "numberUnion", + "mixedUnion" ], "type": "object" } From ddbeafdd109380e2457ad3cd647b31604853309a Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Tue, 25 Nov 2025 08:34:28 -0800 Subject: [PATCH 11/16] rm unused paths --- src/NodeParser/BinaryExpressionNodeParser.ts | 35 ++++---------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index 380680441..f1cc761de 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -15,8 +15,6 @@ export class BinaryExpressionNodeParser implements SubNodeParser { } public createType(node: ts.BinaryExpression, context: Context): BaseType { - // For the purposes of types, assume that binary expressions always - // evaluate to a number. const leftType = this.typeChecker.getTypeAtLocation(node.left); const rightType = this.typeChecker.getTypeAtLocation(node.right); @@ -24,12 +22,10 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return new AnyType(); } - // 1) If either side is string-like → 'string' if (this.isStringLike(leftType) || this.isStringLike(rightType)) { return new StringType(); } - // 2) If both sides are definitely number-like → 'number' if (this.isDefinitelyNumberLike(leftType) && this.isDefinitelyNumberLike(rightType)) { return new NumberType(); } @@ -38,9 +34,9 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return new BooleanType(); } - // 3) Anything else (objects, any, unknown, weird unions, etc.) → - // 'string' because at runtime + will usually go through ToPrimitive and end - // up in the "string concatenation" branch when non-numeric stuff is + // Anything else (objects, any, unknown, weird unions, etc.) return + // 'string' because at runtime + will usually go through ToPrimitive and + // end up in the "string concatenation" branch when non-numeric stuff is // involved. return new StringType(); } @@ -53,7 +49,7 @@ export class BinaryExpressionNodeParser implements SubNodeParser { // Use apparent type to collapse things like literal unions, etc. const type = this.typeChecker.getApparentType(inType); - // Union? Any member being string-like is enough. + // Any union member being string-like is enough. if (type.isUnion()) { return type.types.some((t) => this.isStringLike(t)); } @@ -75,17 +71,10 @@ export class BinaryExpressionNodeParser implements SubNodeParser { private isBoolean(inType: ts.Type): boolean { const type = this.typeChecker.getApparentType(inType); - // Union? Any member being string-like is enough. - if (type.isUnion()) { - return type.types.some((t) => this.isStringLike(t)); - } - - // String primitives + string literals + template literals if (type.flags & ts.TypeFlags.BooleanLike) { return true; } - // Optionally treat String object type as string-like: const symbol = type.getSymbol(); if (symbol && symbol.getName() === "Boolean") { return true; @@ -98,25 +87,13 @@ export class BinaryExpressionNodeParser implements SubNodeParser { // Use apparent type for unions/intersections const type = this.typeChecker.getApparentType(inType); - const typeStr = this.typeChecker.typeToString(type); - if (typeStr === "Number") { - return true; - } - if (type.isUnion()) { // Must be number-like for *all* members to be "definitely number-like" return type.types.every((t) => this.isDefinitelyNumberLike(t)); } - // Number, number literal, enums, bigint etc. - const numericFlags = - ts.TypeFlags.Number | - ts.TypeFlags.NumberLiteral | - ts.TypeFlags.NumberLike | - ts.TypeFlags.BigIntLike | - ts.TypeFlags.EnumLike; - - if (type.flags & numericFlags) { + const typeStr = this.typeChecker.typeToString(type); + if (typeStr === "Number") { return true; } From 160bfb59dd2df594a236869f8c6b4713bad435ae Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Wed, 26 Nov 2025 14:03:18 -0800 Subject: [PATCH 12/16] switch to sub node parsers --- factory/parser.ts | 2 +- src/NodeParser/BinaryExpressionNodeParser.ts | 66 ++++++++------------ test/valid-data/binary-expression/main.ts | 31 +++++++-- 3 files changed, 54 insertions(+), 45 deletions(-) diff --git a/factory/parser.ts b/factory/parser.ts index 40b3ec9ce..9704d97df 100644 --- a/factory/parser.ts +++ b/factory/parser.ts @@ -115,7 +115,7 @@ export function createParser(program: ts.Program, config: CompletedConfig, augme .addNodeParser(new NeverTypeNodeParser()) .addNodeParser(new ObjectTypeNodeParser()) .addNodeParser(new AsExpressionNodeParser(chainNodeParser)) - .addNodeParser(new BinaryExpressionNodeParser(typeChecker)) + .addNodeParser(new BinaryExpressionNodeParser(chainNodeParser)) .addNodeParser(new SatisfiesNodeParser(chainNodeParser)) .addNodeParser(withJsDoc(new ParameterParser(chainNodeParser))) .addNodeParser(new StringLiteralNodeParser()) diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index f1cc761de..ef8d428eb 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -1,24 +1,26 @@ import ts from "typescript"; -import type { Context } from "../NodeParser.js"; +import type { Context, NodeParser } from "../NodeParser.js"; import type { SubNodeParser } from "../SubNodeParser.js"; import { AnyType } from "../Type/AnyType.js"; import type { BaseType } from "../Type/BaseType.js"; +import { BooleanType } from "../Type/BooleanType.js"; +import { LiteralType } from "../Type/LiteralType.js"; import { NumberType } from "../Type/NumberType.js"; import { StringType } from "../Type/StringType.js"; -import { BooleanType } from "../Type/BooleanType.js"; +import { UnionType } from "../Type/UnionType.js"; export class BinaryExpressionNodeParser implements SubNodeParser { - public constructor(protected typeChecker: ts.TypeChecker) {} + public constructor(protected childNodeParser: NodeParser) {} public supportsNode(node: ts.Node): boolean { return node.kind === ts.SyntaxKind.BinaryExpression; } public createType(node: ts.BinaryExpression, context: Context): BaseType { - const leftType = this.typeChecker.getTypeAtLocation(node.left); - const rightType = this.typeChecker.getTypeAtLocation(node.right); + const leftType = this.childNodeParser.createType(node.left, context); + const rightType = this.childNodeParser.createType(node.right, context); - if (this.isAny(leftType) || this.isAny(rightType)) { + if (leftType instanceof AnyType || rightType instanceof AnyType) { return new AnyType(); } @@ -30,7 +32,7 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return new NumberType(); } - if (this.isBoolean(leftType) && this.isBoolean(rightType)) { + if (this.isBooleanLike(leftType) && this.isBooleanLike(rightType)) { return new BooleanType(); } @@ -41,62 +43,48 @@ export class BinaryExpressionNodeParser implements SubNodeParser { return new StringType(); } - private isAny(type: ts.Type): boolean { - return (type.flags & ts.TypeFlags.Any) !== 0; - } - - private isStringLike(inType: ts.Type): boolean { - // Use apparent type to collapse things like literal unions, etc. - const type = this.typeChecker.getApparentType(inType); - - // Any union member being string-like is enough. - if (type.isUnion()) { - return type.types.some((t) => this.isStringLike(t)); + private isStringLike(type: BaseType): boolean { + if (type instanceof StringType) { + return true; } - // String primitives + string literals + template literals - if (type.flags & ts.TypeFlags.StringLike) { + if (type instanceof LiteralType && type.isString()) { return true; } - // Optionally treat String object type as string-like: - const symbol = type.getSymbol(); - if (symbol && symbol.getName() === "String") { - return true; + // Any union member being string-like is enough. + if (type instanceof UnionType) { + return type.getTypes().some((t) => this.isStringLike(t)); } return false; } - private isBoolean(inType: ts.Type): boolean { - const type = this.typeChecker.getApparentType(inType); - - if (type.flags & ts.TypeFlags.BooleanLike) { + private isBooleanLike(type: BaseType): boolean { + if (type instanceof BooleanType) { return true; } - const symbol = type.getSymbol(); - if (symbol && symbol.getName() === "Boolean") { + if (type instanceof LiteralType && typeof type.getValue() === "boolean") { return true; } return false; } - private isDefinitelyNumberLike(inType: ts.Type): boolean { - // Use apparent type for unions/intersections - const type = this.typeChecker.getApparentType(inType); - - if (type.isUnion()) { - // Must be number-like for *all* members to be "definitely number-like" - return type.types.every((t) => this.isDefinitelyNumberLike(t)); + private isDefinitelyNumberLike(type: BaseType): boolean { + if (type instanceof NumberType) { + return true; } - const typeStr = this.typeChecker.typeToString(type); - if (typeStr === "Number") { + if (type instanceof LiteralType && typeof type.getValue() === "number") { return true; } + if (type instanceof UnionType) { + return type.getTypes().every((t) => this.isDefinitelyNumberLike(t)); + } + return false; } } diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index ddf9db4cb..019852c2f 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -2,18 +2,39 @@ type StringUnion = "a" | "b"; type NumberUnion = 10 | 20; type MixedUnion = "c" | 30; +function getAny(): any { + return "test" as any; +} + +function getBoolean(): boolean { + return Math.random() > 0.5; +} + +const anyString: any = getAny(); + +const aStringUnion: StringUnion = "a"; +const bStringUnion: StringUnion = "b"; + +const tenNumberUnion: NumberUnion = 10; +const twentyNumberUnion: NumberUnion = 20; + +const thirtyMixedUnion: MixedUnion = 30; + +const a: boolean = true; +const b: boolean = getBoolean(); + const foo = { numbers: 60 * 5, strings: "a" + "b", - booleans: true && false, - any: 1 + ("test" as any), + booleans: a || b, + any: 1 + anyString, threeNumbers: 60 * 5 + 1, mixedStringAndNumbers: 60 * 5 + " minutes", bigintType: BigInt(123), - stringUnion: ("a" as StringUnion) + ("b" as StringUnion), - numberUnion: (10 as NumberUnion) + (20 as NumberUnion), - mixedUnion: (30 as MixedUnion) + " is a number", + stringUnion: aStringUnion + bStringUnion, + numberUnion: tenNumberUnion + twentyNumberUnion, + mixedUnion: thirtyMixedUnion + " is a number", } as const; export type MyObject = typeof foo; From 4b36ee7a99f2f824e2158ed38a16645804842b12 Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Thu, 27 Nov 2025 14:57:03 -0800 Subject: [PATCH 13/16] rm testfn --- test/utils.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/test/utils.ts b/test/utils.ts index 112d4ff39..ed8da8068 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -109,20 +109,3 @@ export function assertValidSchema( } }; } - -export function getTypeFlagNames(type: ts.Type): string[] { - const flags = type.flags; - const names: string[] = []; - - for (const key of Object.keys(ts.TypeFlags)) { - // filter out the numeric reverse-mapping entries - if (!Number.isNaN(Number(key))) continue; - - const flagValue = ts.TypeFlags[key as keyof ts.TypeFlags] as number; - if ((flags & flagValue) !== 0) { - names.push(key); - } - } - - return names; -} From 43d67325ae42f2c3c27d2351c4fa9c7cf1e2d35a Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Fri, 28 Nov 2025 14:49:30 -0800 Subject: [PATCH 14/16] fix lint --- test/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils.ts b/test/utils.ts index ed8da8068..510ce9dc9 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -4,7 +4,7 @@ import addFormats from "ajv-formats"; import { readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; import stringify from "safe-stable-stringify"; -import ts from "typescript"; +import type ts from "typescript"; import { createFormatter } from "../factory/formatter"; import { createParser } from "../factory/parser"; import { createProgram } from "../factory/program"; From 6ebfa47db3a635b10c941ca42f5fc2e926a808cf Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Sat, 29 Nov 2025 15:29:50 -0800 Subject: [PATCH 15/16] add condition for more test coverage --- src/NodeParser/BinaryExpressionNodeParser.ts | 10 ++++++ test/valid-data/binary-expression/main.ts | 32 +++++++++++++++---- test/valid-data/binary-expression/schema.json | 2 ++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index ef8d428eb..e43dd44b5 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -8,6 +8,7 @@ import { LiteralType } from "../Type/LiteralType.js"; import { NumberType } from "../Type/NumberType.js"; import { StringType } from "../Type/StringType.js"; import { UnionType } from "../Type/UnionType.js"; +import { AliasType } from "../Type/AliasType.js"; export class BinaryExpressionNodeParser implements SubNodeParser { public constructor(protected childNodeParser: NodeParser) {} @@ -44,6 +45,10 @@ export class BinaryExpressionNodeParser implements SubNodeParser { } private isStringLike(type: BaseType): boolean { + if (type instanceof AliasType) { + return this.isStringLike(type.getType()); + } + if (type instanceof StringType) { return true; } @@ -73,6 +78,10 @@ export class BinaryExpressionNodeParser implements SubNodeParser { } private isDefinitelyNumberLike(type: BaseType): boolean { + if (type instanceof AliasType) { + return this.isDefinitelyNumberLike(type.getType()); + } + if (type instanceof NumberType) { return true; } @@ -82,6 +91,7 @@ export class BinaryExpressionNodeParser implements SubNodeParser { } if (type instanceof UnionType) { + console.log(`XXX thingy here`); return type.getTypes().every((t) => this.isDefinitelyNumberLike(t)); } diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index 019852c2f..4dd5e5b19 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -10,19 +10,37 @@ function getBoolean(): boolean { return Math.random() > 0.5; } +function getStringUnion(): StringUnion { + return Math.random() > 0.5 ? "a" : "b"; +} + +function getNumberUnion(): NumberUnion { + return Math.random() > 0.5 ? 10 : 20; +} + +function getMixedUnion(): MixedUnion { + return Math.random() > 0.5 ? "c" : 30; +} + +function getUnknown(): unknown { + return "unknown value"; +} + const anyString: any = getAny(); -const aStringUnion: StringUnion = "a"; -const bStringUnion: StringUnion = "b"; +const aStringUnion: StringUnion = getStringUnion(); +const bStringUnion: StringUnion = getStringUnion(); -const tenNumberUnion: NumberUnion = 10; -const twentyNumberUnion: NumberUnion = 20; +const tenNumberUnion: NumberUnion = getNumberUnion(); +const twentyNumberUnion: NumberUnion = getNumberUnion(); -const thirtyMixedUnion: MixedUnion = 30; +const thirtyMixedUnion: MixedUnion = getMixedUnion(); -const a: boolean = true; +const a: boolean = getBoolean(); const b: boolean = getBoolean(); +const unknownValue: unknown = getUnknown(); + const foo = { numbers: 60 * 5, strings: "a" + "b", @@ -32,6 +50,8 @@ const foo = { mixedStringAndNumbers: 60 * 5 + " minutes", bigintType: BigInt(123), + unknowns: unknownValue && unknownValue, + stringUnion: aStringUnion + bStringUnion, numberUnion: tenNumberUnion + twentyNumberUnion, mixedUnion: thirtyMixedUnion + " is a number", diff --git a/test/valid-data/binary-expression/schema.json b/test/valid-data/binary-expression/schema.json index addac7932..57854f776 100644 --- a/test/valid-data/binary-expression/schema.json +++ b/test/valid-data/binary-expression/schema.json @@ -13,6 +13,7 @@ "bigintType": { "type": "number" }, "numberUnion": { "type": "number" }, "stringUnion": { "type": "string" }, + "unknowns": { "type": "string" }, "mixedUnion": { "type": "string" }, "any": {} }, @@ -24,6 +25,7 @@ "threeNumbers", "mixedStringAndNumbers", "bigintType", + "unknowns", "stringUnion", "numberUnion", "mixedUnion" From da74fe473cf9885728a4bc369aeb08347d1cc6be Mon Sep 17 00:00:00 2001 From: Sam Sudar Date: Sat, 29 Nov 2025 15:39:35 -0800 Subject: [PATCH 16/16] add more tests --- src/NodeParser/BinaryExpressionNodeParser.ts | 1 - test/valid-data/binary-expression/main.ts | 10 ++++++++-- test/valid-data/binary-expression/schema.json | 12 ++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/NodeParser/BinaryExpressionNodeParser.ts b/src/NodeParser/BinaryExpressionNodeParser.ts index e43dd44b5..e99ac3254 100644 --- a/src/NodeParser/BinaryExpressionNodeParser.ts +++ b/src/NodeParser/BinaryExpressionNodeParser.ts @@ -91,7 +91,6 @@ export class BinaryExpressionNodeParser implements SubNodeParser { } if (type instanceof UnionType) { - console.log(`XXX thingy here`); return type.getTypes().every((t) => this.isDefinitelyNumberLike(t)); } diff --git a/test/valid-data/binary-expression/main.ts b/test/valid-data/binary-expression/main.ts index 4dd5e5b19..6ebb4a60c 100644 --- a/test/valid-data/binary-expression/main.ts +++ b/test/valid-data/binary-expression/main.ts @@ -26,6 +26,10 @@ function getUnknown(): unknown { return "unknown value"; } +function getStringType(): string { + return Math.random() > 0.5 ? "hello" : "world"; +} + const anyString: any = getAny(); const aStringUnion: StringUnion = getStringUnion(); @@ -43,8 +47,10 @@ const unknownValue: unknown = getUnknown(); const foo = { numbers: 60 * 5, - strings: "a" + "b", - booleans: a || b, + stringLiterals: "a" + "b", + stringTypes: getStringType() + getStringType(), + booleanTypes: a || b, + booleanLiterals: true || false, any: 1 + anyString, threeNumbers: 60 * 5 + 1, mixedStringAndNumbers: 60 * 5 + " minutes", diff --git a/test/valid-data/binary-expression/schema.json b/test/valid-data/binary-expression/schema.json index 57854f776..4d4857dc4 100644 --- a/test/valid-data/binary-expression/schema.json +++ b/test/valid-data/binary-expression/schema.json @@ -7,9 +7,11 @@ "properties": { "numbers": { "type": "number" }, "threeNumbers": { "type": "number" }, - "strings": { "type": "string" }, + "stringLiterals": { "type": "string" }, + "stringTypes": { "type": "string" }, "mixedStringAndNumbers": { "type": "string" }, - "booleans": { "type": "boolean" }, + "booleanTypes": { "type": "boolean" }, + "booleanLiterals": { "type": "boolean" }, "bigintType": { "type": "number" }, "numberUnion": { "type": "number" }, "stringUnion": { "type": "string" }, @@ -19,8 +21,10 @@ }, "required": [ "numbers", - "strings", - "booleans", + "stringLiterals", + "stringTypes", + "booleanTypes", + "booleanLiterals", "any", "threeNumbers", "mixedStringAndNumbers",