From 5547207293c9f787b6fc3f3a6f729d9d977bd472 Mon Sep 17 00:00:00 2001 From: idhanth <109756003+idhanth@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:56:45 -0400 Subject: [PATCH 1/2] fix(ts): support JSON-compatible standard-library helper types in schemas The TypeScript validator compiles schemas with noLib and a minimal replacement library that lacked ReadonlyArray and the common type helpers, so schemas using them failed with "Cannot find name" errors. Add ReadonlyArray, Partial, Required, Readonly, Pick, Record, Exclude, Extract, Omit, and NonNullable to the minimal library, with positive and negative tests and a docs note on supported types. Fixes #452 Co-Authored-By: Claude Opus 5.5 --- site/src/docs/typescript/basic-usage.md | 4 ++ typescript/src/ts/validate.ts | 12 ++++- typescript/test/validate.test.ts | 69 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/site/src/docs/typescript/basic-usage.md b/site/src/docs/typescript/basic-usage.md index 9bf343b2..30399446 100644 --- a/site/src/docs/typescript/basic-usage.md +++ b/site/src/docs/typescript/basic-usage.md @@ -112,6 +112,10 @@ export interface SentimentResponse { } ``` +The validator type-checks against a minimal library rather than the full TypeScript standard library, since responses are always plain JSON. +Besides primitives, object types, arrays, and unions, schemas can use `ReadonlyArray`, `Partial`, `Required`, `Readonly`, `Pick`, `Omit`, `Record`, `Exclude`, `Extract`, and `NonNullable`. +Runtime types such as `Date`, `Map`, or `Set` aren't JSON values and aren't available; describe their JSON representation instead (for example, an ISO date `string`). + It also means we will need to manually load up an input `.ts` file verbatim. ```ts diff --git a/typescript/src/ts/validate.ts b/typescript/src/ts/validate.ts index b2b0b9c5..78996984 100644 --- a/typescript/src/ts/validate.ts +++ b/typescript/src/ts/validate.ts @@ -27,7 +27,17 @@ interface NewableFunction extends Function {} interface String { readonly length: number } interface Boolean { valueOf(): boolean } interface Number { valueOf(): number } -interface RegExp { test(string: string): boolean }`; +interface RegExp { test(string: string): boolean } +interface ReadonlyArray { readonly length: number, readonly [n: number]: T } +type Partial = { [P in keyof T]?: T[P] }; +type Required = { [P in keyof T]-?: T[P] }; +type Readonly = { readonly [P in keyof T]: T[P] }; +type Pick = { [P in K]: T[P] }; +type Record = { [P in K]: T }; +type Exclude = T extends U ? never : T; +type Extract = T extends U ? T : never; +type Omit = Pick>; +type NonNullable = T & {};`; const configFileName = "/tsconfig.json"; const libFileName = "/lib.d.ts"; diff --git a/typescript/test/validate.test.ts b/typescript/test/validate.test.ts index 31ef89fa..db4fedff 100644 --- a/typescript/test/validate.test.ts +++ b/typescript/test/validate.test.ts @@ -193,4 +193,73 @@ describe("createTypeScriptJsonValidator", () => { assert.ok(result.data.includes("const json: Point =")); }); }); + + describe("standard-library helper types", () => { + const helperSchema = ` +export interface Payload { + values: ReadonlyArray; + readonlySyntax: readonly number[]; + counts: Record; + metadata: Partial<{ name: string; size: number }>; + required: Required<{ id?: string }>; + frozen: Readonly<{ x: number }>; + picked: Pick<{ a: string; b: number }, "a">; + omitted: Omit<{ a: string; b: number }, "b">; + color: Exclude<"red" | "green" | "blue", "green">; + kind: Extract<"a" | "b" | 1, string>; + label: NonNullable; +}`; + const validPayload = { + values: ["a"], + readonlySyntax: [1], + counts: { a: 1 }, + metadata: {}, + required: { id: "x" }, + frozen: { x: 1 }, + picked: { a: "x" }, + omitted: { a: "x" }, + color: "red", + kind: "a", + label: "l" + }; + + it("accepts a payload that satisfies every helper type", () => { + const v = validatorFor(helperSchema, "Payload"); + const result = v.validate(validPayload); + assert.ok(result.success, `expected validation to succeed, got: ${!result.success && result.message}`); + }); + + it("accepts the reproduction from issue #452", () => { + const v = validatorFor(` +export interface Payload { + values: ReadonlyArray; + counts: Record; + metadata: Partial<{ name: string }>; +}`, "Payload"); + const result = v.validate({ values: ["a"], counts: { a: 1 }, metadata: {} }); + assert.ok(result.success, `expected validation to succeed, got: ${!result.success && result.message}`); + }); + + const invalidCases: [string, object, RegExp][] = [ + ["ReadonlyArray element type", { values: [1] }, /'number' is not assignable to type 'string'/], + ["readonly array syntax element type", { readonlySyntax: ["x"] }, /'string' is not assignable to type 'number'/], + ["Record value type", { counts: { a: "1" } }, /'string' is not assignable to type 'number'/], + ["Partial property type", { metadata: { name: 1 } }, /'number' is not assignable to type 'string'/], + ["Required missing property", { required: {} }, /Property 'id' is missing/], + ["Readonly property type", { frozen: { x: "1" } }, /'string' is not assignable to type 'number'/], + ["Pick property type", { picked: { a: 1 } }, /'number' is not assignable to type 'string'/], + ["Omit removed property", { omitted: { a: "x", b: 1 } }, /'"?b"?' does not exist/], + ["Exclude removed member", { color: "green" }, /'"green"' is not assignable/], + ["Extract non-matching member", { kind: 1 }, /'number' is not assignable/], + ["NonNullable null", { label: null }, /'null' is not assignable to type 'string'/], + ]; + for (const [name, override, expected] of invalidCases) { + it(`rejects an invalid value for ${name}`, () => { + const v = validatorFor(helperSchema, "Payload"); + const result = v.validate({ ...validPayload, ...override }); + assert.ok(!result.success, "expected validation to fail"); + assert.match(result.message, expected); + }); + } + }); }); From e5535b7ee06aec2bc7ba4dde36932a7ffedfd1ea Mon Sep 17 00:00:00 2001 From: idhanth <109756003+idhanth@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:46:11 -0400 Subject: [PATCH 2/2] fix(ts): support ReturnType, Awaited, and string intrinsic types in schemas Add ReturnType, Awaited, Uppercase, Lowercase, Capitalize, and Uncapitalize to the validator's minimal library, with the type-only PromiseLike and Promise declarations Awaited needs. A Promise property still rejects every JSON value. Tests cover valid and invalid values for each helper. Co-Authored-By: Claude Opus 5.5 --- site/src/docs/typescript/basic-usage.md | 2 +- typescript/src/ts/validate.ts | 10 +++++++- typescript/test/validate.test.ts | 34 ++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/site/src/docs/typescript/basic-usage.md b/site/src/docs/typescript/basic-usage.md index 30399446..e4871fc2 100644 --- a/site/src/docs/typescript/basic-usage.md +++ b/site/src/docs/typescript/basic-usage.md @@ -113,7 +113,7 @@ export interface SentimentResponse { ``` The validator type-checks against a minimal library rather than the full TypeScript standard library, since responses are always plain JSON. -Besides primitives, object types, arrays, and unions, schemas can use `ReadonlyArray`, `Partial`, `Required`, `Readonly`, `Pick`, `Omit`, `Record`, `Exclude`, `Extract`, and `NonNullable`. +Besides primitives, object types, arrays, and unions, schemas can use `ReadonlyArray`, `Partial`, `Required`, `Readonly`, `Pick`, `Omit`, `Record`, `Exclude`, `Extract`, `NonNullable`, `ReturnType`, `Awaited`, `Uppercase`, `Lowercase`, `Capitalize`, and `Uncapitalize`. Runtime types such as `Date`, `Map`, or `Set` aren't JSON values and aren't available; describe their JSON representation instead (for example, an ISO date `string`). It also means we will need to manually load up an input `.ts` file verbatim. diff --git a/typescript/src/ts/validate.ts b/typescript/src/ts/validate.ts index 78996984..0363db99 100644 --- a/typescript/src/ts/validate.ts +++ b/typescript/src/ts/validate.ts @@ -37,7 +37,15 @@ type Record = { [P in K]: T }; type Exclude = T extends U ? never : T; type Extract = T extends U ? T : never; type Omit = Pick>; -type NonNullable = T & {};`; +type NonNullable = T & {}; +interface PromiseLike { then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): PromiseLike } +interface Promise { then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise; catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): Promise } +type ReturnType any> = T extends (...args: any) => infer R ? R : any; +type Awaited = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F, ...args: infer _): any } ? F extends ((value: infer V, ...args: infer _) => any) ? Awaited : never : T; +type Uppercase = intrinsic; +type Lowercase = intrinsic; +type Capitalize = intrinsic; +type Uncapitalize = intrinsic;`; const configFileName = "/tsconfig.json"; const libFileName = "/lib.d.ts"; diff --git a/typescript/test/validate.test.ts b/typescript/test/validate.test.ts index db4fedff..b74aa2d3 100644 --- a/typescript/test/validate.test.ts +++ b/typescript/test/validate.test.ts @@ -196,6 +196,8 @@ describe("createTypeScriptJsonValidator", () => { describe("standard-library helper types", () => { const helperSchema = ` +declare function makeConfig(): { port: number; host: string }; +declare function load(): Promise<{ id: string }>; export interface Payload { values: ReadonlyArray; readonlySyntax: readonly number[]; @@ -208,6 +210,13 @@ export interface Payload { color: Exclude<"red" | "green" | "blue", "green">; kind: Extract<"a" | "b" | 1, string>; label: NonNullable; + config: ReturnType; + loaded: Awaited>; + nested: Awaited>>; + upper: Uppercase<"get" | "post">; + lower: Lowercase<"HeLLo">; + capitalized: Capitalize<"hello">; + uncapitalized: Uncapitalize<"Hello">; }`; const validPayload = { values: ["a"], @@ -220,7 +229,14 @@ export interface Payload { omitted: { a: "x" }, color: "red", kind: "a", - label: "l" + label: "l", + config: { port: 1, host: "h" }, + loaded: { id: "x" }, + nested: 1, + upper: "POST", + lower: "hello", + capitalized: "Hello", + uncapitalized: "hello" }; it("accepts a payload that satisfies every helper type", () => { @@ -252,7 +268,23 @@ export interface Payload { ["Exclude removed member", { color: "green" }, /'"green"' is not assignable/], ["Extract non-matching member", { kind: 1 }, /'number' is not assignable/], ["NonNullable null", { label: null }, /'null' is not assignable to type 'string'/], + ["ReturnType property type", { config: { port: "1", host: "h" } }, /'string' is not assignable to type 'number'/], + ["Awaited property type", { loaded: { id: 1 } }, /'number' is not assignable to type 'string'/], + ["nested Awaited type", { nested: "1" }, /'string' is not assignable to type 'number'/], + ["Uppercase member", { upper: "PUT" }, /'"PUT"' is not assignable/], + ["Lowercase value", { lower: "HELLO" }, /'"HELLO"' is not assignable to type '"hello"'/], + ["Capitalize value", { capitalized: "hello" }, /'"hello"' is not assignable to type '"Hello"'/], + ["Uncapitalize value", { uncapitalized: "Hello" }, /'"Hello"' is not assignable to type '"hello"'/], ]; + + it("rejects any JSON value for a Promise property", () => { + const v = validatorFor(`export interface Holder { value: Promise; }`, "Holder"); + for (const value of [1, {}, { then: "x" }]) { + const result = v.validate({ value }); + assert.ok(!result.success, `expected validation to fail for ${JSON.stringify(value)}`); + } + }); + for (const [name, override, expected] of invalidCases) { it(`rejects an invalid value for ${name}`, () => { const v = validatorFor(helperSchema, "Payload");