Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"scripts": {
"build": "tsc -p src",
"build-all": "npm run build --workspaces",
"test": "tsc -p test && node --test out/validate.test.js",
"prepare": "npm run build-all",
"prepublishOnly": "node -e \"require('fs').copyFileSync('../SECURITY.md','SECURITY.md')\"",
"postpublish": "node -e \"require('fs').unlinkSync('SECURITY.md')\"",
Expand Down
43 changes: 42 additions & 1 deletion typescript/src/ts/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,53 @@ export function createTypeScriptJsonValidator<T extends object = object>(schema:
const syntacticDiagnostics = program.getSyntacticDiagnostics();
const programDiagnostics = syntacticDiagnostics.length ? syntacticDiagnostics : program.getSemanticDiagnostics();
if (programDiagnostics.length) {
const diagnostics = programDiagnostics.map(d => typeof d.messageText === "string" ? d.messageText : d.messageText.messageText).join("\n");
const checker = program.getTypeChecker();
Comment thread
robgruen marked this conversation as resolved.
const diagnostics = programDiagnostics.map(d => {
const message = ts.flattenDiagnosticMessageText(d.messageText, "\n");
// TS error 2740 truncates the missing-properties list to 4 items ("and N more").
// Use the type checker to reconstruct the full list of missing required properties.
if (d.code === 2740 && d.file && d.start !== undefined) {
return expandMissingPropertiesMessage(checker, d.file, d.start) ?? message;
}
return message;
}).join("\n");
return error(diagnostics);
}
return success(jsonObject as T);
}

/**
* For TypeScript error 2740 (missing required properties, truncated with "and N more"),
* uses the type checker to compute the full list of missing required properties from the
* variable declaration at `position` in `file`. Returns `undefined` if the declaration
* cannot be located or yields no missing properties (fallback to the original message).
*/
function expandMissingPropertiesMessage(checker: ts.TypeChecker, file: ts.SourceFile, position: number): string | undefined {
Comment thread
robgruen marked this conversation as resolved.
for (const stmt of file.statements) {
if (ts.isVariableStatement(stmt)) {
for (const decl of stmt.declarationList.declarations) {
// Match the specific declaration that spans the diagnostic position.
// Use getStart() to exclude leading trivia from the range check.
if (decl.getStart(file) <= position && position <= decl.end &&
decl.type && ts.isTypeReferenceNode(decl.type) && decl.initializer) {
const targetType = checker.getTypeAtLocation(decl.type);
const sourceType = checker.getTypeAtLocation(decl.initializer);
const sourceProps = new Set(sourceType.getProperties().map(p => p.name));
const missingProps = targetType.getProperties()
.filter(p => !(p.flags & ts.SymbolFlags.Optional) && !sourceProps.has(p.name))
.map(p => p.name);
if (missingProps.length > 0) {
const srcStr = checker.typeToString(sourceType, undefined, ts.TypeFormatFlags.NoTruncation);
const tgtStr = checker.typeToString(targetType, undefined, ts.TypeFormatFlags.NoTruncation);
return `Type '${srcStr}' is missing the following properties from type '${tgtStr}': ${missingProps.join(", ")}`;
}
}
}
}
}
return undefined;
}

function createModuleTextFromJson(jsonObject: object) {
return success(`import { ${typeName} } from './schema';\nconst json: ${typeName} = ${JSON.stringify(jsonObject, undefined, 2)};\n`);
}
Expand Down
15 changes: 15 additions & 0 deletions typescript/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["es2021"],
"module": "node16",
"moduleResolution": "node16",
"types": ["node"],
"esModuleInterop": true,
"outDir": "../out",
"rootDir": ".",
"skipLibCheck": true,
"strict": true
},
"include": ["./**/*.ts"]
}
196 changes: 196 additions & 0 deletions typescript/test/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTypeScriptJsonValidator } from "../dist/ts/index.js";

// ---------------------------------------------------------------------------
// Shared schemas
// ---------------------------------------------------------------------------

// Schema with 8 required properties — enough to trigger TS error 2740
// ("and N more") when several are absent.
const largeSchema = `
export interface Doc {
id: number;
title: string;
slug: string;
abstract: string;
description: string;
author: string;
date: string;
category: string;
}`;

// Schema with a mix of required and optional properties.
const mixedSchema = `
export interface Item {
id: number;
name: string;
tag?: string;
extra1: string;
extra2: string;
extra3: string;
extra4: string;
extra5: string;
}`;

// Small schema for simpler error cases.
const smallSchema = `
export interface Point {
x: number;
y: number;
}`;

// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function validatorFor<T extends object>(schema: string, typeName: string) {
return createTypeScriptJsonValidator<T>(schema, typeName);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("createTypeScriptJsonValidator", () => {

describe("valid objects", () => {
it("accepts an object that satisfies all required properties", () => {
const v = validatorFor(largeSchema, "Doc");
const result = v.validate({
id: 1, title: "T", slug: "s", abstract: "a",
description: "d", author: "au", date: "2024-01-01", category: "c"
});
assert.ok(result.success, "expected validation to succeed");
});

it("accepts an object that supplies optional properties", () => {
const v = validatorFor(mixedSchema, "Item");
const result = v.validate({
id: 1, name: "n", tag: "t",
extra1: "a", extra2: "b", extra3: "c", extra4: "d", extra5: "e"
});
assert.ok(result.success, "expected validation to succeed with optional props");
});

it("accepts an object that omits optional properties", () => {
const v = validatorFor(mixedSchema, "Item");
const result = v.validate({
id: 1, name: "n",
extra1: "a", extra2: "b", extra3: "c", extra4: "d", extra5: "e"
});
assert.ok(result.success, "expected validation to succeed without optional props");
});
});

describe("type mismatch errors (backward-compatible: unchanged code path)", () => {
it("reports a type error when a property has the wrong type", () => {
const v = validatorFor(smallSchema, "Point");
const result = v.validate({ x: "not-a-number", y: 2 });
assert.ok(!result.success, "expected validation to fail");
assert.ok(
result.message.includes("not assignable to type"),
`expected type-mismatch message, got: ${result.message}`
);
});
});

describe("single missing required property (TS error 2741, unchanged code path)", () => {
it("reports the missing property by name", () => {
const v = validatorFor(smallSchema, "Point");
const result = v.validate({ x: 1 });
assert.ok(!result.success, "expected validation to fail");
assert.ok(
result.message.includes("'y'") && result.message.includes("missing"),
`expected missing-property message, got: ${result.message}`
);
});
});

describe("2–5 missing required properties (TS error 2739, unchanged code path)", () => {
it("lists all missing properties without truncation for 4 missing", () => {
const schema = `export interface T { a: number; b: string; c: boolean; d: number; e: string; }`;
const v = validatorFor(schema, "T");
// Provide only 'a', missing b/c/d/e
const result = v.validate({ a: 1 });
assert.ok(!result.success, "expected validation to fail");
for (const prop of ["b", "c", "d", "e"]) {
assert.ok(
result.message.includes(prop),
`expected '${prop}' in error message, got: ${result.message}`
);
}
});
});

describe("6+ missing required properties (TS error 2740 — the truncation fix)", () => {
it("returns the full list of missing properties without truncation", () => {
const v = validatorFor(largeSchema, "Doc");
// Provide only id and title; slug/abstract/description/author/date/category are missing
const result = v.validate({ id: 1, title: "Hello" });
assert.ok(!result.success, "expected validation to fail");

const missing = ["slug", "abstract", "description", "author", "date", "category"];
for (const prop of missing) {
assert.ok(
result.message.includes(prop),
`expected '${prop}' in error message but got: ${result.message}`
);
}
assert.ok(
!result.message.includes("more"),
`error message should not contain "more" (truncation indicator), got: ${result.message}`
);
});

it("excludes optional properties from the missing-properties list", () => {
const v = validatorFor(mixedSchema, "Item");
// Provide only id — name/extra1..5 are missing required; tag is optional
const result = v.validate({ id: 1 });
assert.ok(!result.success, "expected validation to fail");

const requiredMissing = ["name", "extra1", "extra2", "extra3", "extra4", "extra5"];
for (const prop of requiredMissing) {
assert.ok(
result.message.includes(prop),
`expected '${prop}' in error message, got: ${result.message}`
);
}
assert.ok(
!result.message.includes("tag"),
`optional property 'tag' should not appear in missing-properties message, got: ${result.message}`
);
});

it("does not include 'and N more' truncation text", () => {
const v = validatorFor(largeSchema, "Doc");
const result = v.validate({});
assert.ok(!result.success, "expected validation to fail");
assert.ok(
!/ and \d+ more/.test(result.message),
`message should not contain "and N more", got: ${result.message}`
);
});
});

describe("getSchemaText and getTypeName", () => {
it("returns the original schema text", () => {
const v = validatorFor(smallSchema, "Point");
assert.strictEqual(v.getSchemaText(), smallSchema);
});

it("returns the correct type name", () => {
const v = validatorFor(smallSchema, "Point");
assert.strictEqual(v.getTypeName(), "Point");
});
});

describe("createModuleTextFromJson", () => {
it("produces valid TypeScript module text for a simple object", () => {
const v = validatorFor(smallSchema, "Point");
const result = v.createModuleTextFromJson({ x: 1, y: 2 });
assert.ok(result.success, "expected module text creation to succeed");
assert.ok(result.data.includes("import { Point } from './schema'"));
assert.ok(result.data.includes("const json: Point ="));
});
});
});
Loading