From a060858e6e04d1244c9adbd238278b94649f7662 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 21:05:50 +0000 Subject: [PATCH] Implement typeCompat function for deep structural schema compatibility checking --- src/analysis/index.ts | 2 +- src/analysis/type-compat.ts | 279 +++++++++++++++++++- test/analysis/type-compat.test.ts | 415 +++++++++++++++++++++++++++++- 3 files changed, 690 insertions(+), 6 deletions(-) diff --git a/src/analysis/index.ts b/src/analysis/index.ts index 8cec2e9..76c0da4 100644 --- a/src/analysis/index.ts +++ b/src/analysis/index.ts @@ -1 +1 @@ -export {}; \ No newline at end of file +export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js"; \ No newline at end of file diff --git a/src/analysis/type-compat.ts b/src/analysis/type-compat.ts index 8cec2e9..ed36cb0 100644 --- a/src/analysis/type-compat.ts +++ b/src/analysis/type-compat.ts @@ -1 +1,278 @@ -export {}; \ No newline at end of file +import { KindGuard, Kind, type TSchema } from "@alkdev/typebox"; + +export interface TypeMismatch { + path: string; + expected: string; + actual: string; +} + +export interface TypeCompatResult { + compatible: boolean; + detail?: string; + mismatches?: TypeMismatch[]; +} + +function schemaKind(schema: TSchema): string { + return (schema as unknown as Record)[Kind] as string ?? "unknown"; +} + +function typeName(schema: TSchema): string { + if (KindGuard.IsUnknown(schema)) return "unknown"; + if (KindGuard.IsAny(schema)) return "any"; + if (KindGuard.IsNull(schema)) return "null"; + if (KindGuard.IsString(schema)) return "string"; + if (KindGuard.IsNumber(schema)) return "number"; + if (KindGuard.IsInteger(schema)) return "integer"; + if (KindGuard.IsBoolean(schema)) return "boolean"; + if (KindGuard.IsLiteral(schema)) { + const val = schema.const; + return typeof val === "string" ? `"${val}"` : String(val); + } + if (KindGuard.IsArray(schema)) return "array"; + if (KindGuard.IsObject(schema)) return "object"; + if (KindGuard.IsUnion(schema)) { + const anyOf = schema.anyOf as TSchema[] | undefined; + if (anyOf) return anyOf.map(typeName).join(" | "); + return "union"; + } + if (KindGuard.IsRecord(schema)) return "record"; + if (KindGuard.IsTuple(schema)) return "tuple"; + if (KindGuard.IsNever(schema)) return "never"; + return schemaKind(schema); +} + +function isUnknownType(schema: TSchema): boolean { + if (KindGuard.IsUnknown(schema) || KindGuard.IsAny(schema)) return true; + return false; +} + +function unwrapOptional(schema: TSchema): TSchema { + if (KindGuard.IsOptional(schema)) { + return (schema as unknown as Record)["type"] === "object" + ? schema + : ((schema as unknown as Record)["anyOf"] as TSchema[] | undefined)?.[0] ?? schema; + } + return schema; +} + +function unwrapReadonlyOptional(schema: TSchema): TSchema { + let s = schema; + if (KindGuard.IsReadonly(s)) { + const inner = (s as unknown as Record)["anyOf"] as TSchema[] | undefined; + if (inner) s = inner[0]!; + } + return unwrapOptional(s); +} + +function isOptionalField(schema: TSchema): boolean { + if (KindGuard.IsOptional(schema)) return true; + if (KindGuard.IsReadonly(schema)) { + const inner = (schema as unknown as Record)["anyOf"] as TSchema[] | undefined; + if (inner && KindGuard.IsOptional(inner[0]!)) return true; + } + return false; +} + +function isSupertypeOf(outer: TSchema, inner: TSchema): boolean { + if (KindGuard.IsUnion(outer)) { + const anyOf = outer.anyOf as TSchema[] | undefined; + if (!anyOf) return false; + return anyOf.some((variant) => isSupertypeOf(variant, inner)); + } + if (KindGuard.IsUnion(inner)) { + const anyOf = inner.anyOf as TSchema[] | undefined; + if (!anyOf) return false; + return anyOf.every((variant) => isSupertypeOf(outer, variant)); + } + if (schemasEqual(outer, inner)) return true; + if (KindGuard.IsLiteral(outer) && KindGuard.IsLiteral(inner)) { + return outer.const === inner.const; + } + if (KindGuard.IsString(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "string") return true; + if (KindGuard.IsNumber(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "number") return true; + if (KindGuard.IsInteger(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "number") return true; + if (KindGuard.IsBoolean(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "boolean") return true; + return false; +} + +function schemasEqual(a: TSchema, b: TSchema): boolean { + const aKind = (a as unknown as Record)[Kind]; + const bKind = (b as unknown as Record)[Kind]; + if (aKind !== bKind) return false; + if (KindGuard.IsString(a) && KindGuard.IsString(b)) return true; + if (KindGuard.IsNumber(a) && KindGuard.IsNumber(b)) return true; + if (KindGuard.IsInteger(a) && KindGuard.IsInteger(b)) return true; + if (KindGuard.IsBoolean(a) && KindGuard.IsBoolean(b)) return true; + if (KindGuard.IsNull(a) && KindGuard.IsNull(b)) return true; + if (KindGuard.IsUnknown(a) && KindGuard.IsUnknown(b)) return true; + if (KindGuard.IsAny(a) && KindGuard.IsAny(b)) return true; + if (KindGuard.IsLiteral(a) && KindGuard.IsLiteral(b)) return a.const === b.const; + return false; +} + +function checkCompat(output: TSchema, input: TSchema, path: string, mismatches: TypeMismatch[]): void { + const outUnwrapped = unwrapReadonlyOptional(output); + const inUnwrapped = unwrapReadonlyOptional(input); + + if (isUnknownType(outUnwrapped) || isUnknownType(inUnwrapped)) { + mismatches.push({ + path, + expected: typeName(inUnwrapped), + actual: typeName(outUnwrapped), + }); + return; + } + + if (KindGuard.IsNull(outUnwrapped) && KindGuard.IsNull(inUnwrapped)) return; + if (KindGuard.IsString(outUnwrapped) && KindGuard.IsString(inUnwrapped)) return; + if (KindGuard.IsNumber(outUnwrapped) && KindGuard.IsNumber(inUnwrapped)) return; + if (KindGuard.IsInteger(outUnwrapped) && KindGuard.IsInteger(inUnwrapped)) return; + if (KindGuard.IsInteger(outUnwrapped) && KindGuard.IsNumber(inUnwrapped)) return; + if (KindGuard.IsBoolean(outUnwrapped) && KindGuard.IsBoolean(inUnwrapped)) return; + if (KindGuard.IsLiteral(outUnwrapped) && KindGuard.IsLiteral(inUnwrapped)) { + if (outUnwrapped.const !== inUnwrapped.const) { + mismatches.push({ + path, + expected: typeName(inUnwrapped), + actual: typeName(outUnwrapped), + }); + } + return; + } + + if (KindGuard.IsLiteral(outUnwrapped)) { + if (!isSupertypeOf(inUnwrapped, outUnwrapped)) { + mismatches.push({ + path, + expected: typeName(inUnwrapped), + actual: typeName(outUnwrapped), + }); + } + return; + } + + if (KindGuard.IsUnion(inUnwrapped) && !KindGuard.IsUnion(outUnwrapped)) { + if (!isSupertypeOf(inUnwrapped, outUnwrapped)) { + mismatches.push({ + path, + expected: typeName(inUnwrapped), + actual: typeName(outUnwrapped), + }); + } + return; + } + + if (KindGuard.IsUnion(outUnwrapped) && !KindGuard.IsUnion(inUnwrapped)) { + const anyOf = outUnwrapped.anyOf as TSchema[] | undefined; + if (anyOf) { + const allCompat = anyOf.every((variant) => { + const subMismatches: TypeMismatch[] = []; + checkCompat(variant, inUnwrapped, path, subMismatches); + return subMismatches.length === 0; + }); + if (!allCompat) { + mismatches.push({ + path, + expected: typeName(inUnwrapped), + actual: typeName(outUnwrapped), + }); + } + } + return; + } + + if (KindGuard.IsUnion(outUnwrapped) && KindGuard.IsUnion(inUnwrapped)) { + const outAnyOf = outUnwrapped.anyOf as TSchema[] | undefined; + const inAnyOf = inUnwrapped.anyOf as TSchema[] | undefined; + if (outAnyOf && inAnyOf) { + for (const outVariant of outAnyOf) { + const covered = inAnyOf.some((inVariant) => { + const sub: TypeMismatch[] = []; + checkCompat(outVariant, inVariant, path, sub); + return sub.length === 0; + }); + if (!covered) { + mismatches.push({ + path, + expected: typeName(inUnwrapped), + actual: typeName(outUnwrapped), + }); + return; + } + } + } + return; + } + + if (KindGuard.IsArray(outUnwrapped) && KindGuard.IsArray(inUnwrapped)) { + const outItems = outUnwrapped.items as TSchema | undefined; + const inItems = inUnwrapped.items as TSchema | undefined; + if (outItems && inItems) { + checkCompat(outItems, inItems, `${path}[]`, mismatches); + } + return; + } + + if (KindGuard.IsObject(outUnwrapped) && KindGuard.IsObject(inUnwrapped)) { + const outProps = outUnwrapped.properties as Record | undefined; + const inProps = inUnwrapped.properties as Record | undefined; + if (inProps) { + for (const key of Object.keys(inProps)) { + const inField = inProps[key]!; + const isOpt = isOptionalField(inField); + if (outProps && key in outProps) { + checkCompat(outProps[key]!, inField, `${path}/${key}`, mismatches); + } else if (!isOpt) { + mismatches.push({ + path: `${path}/${key}`, + expected: typeName(inField), + actual: "missing", + }); + } + } + } + return; + } + + if (KindGuard.IsRecord(outUnwrapped) && KindGuard.IsRecord(inUnwrapped)) { + const outVal = outUnwrapped.additionalProperties as TSchema | undefined; + const inVal = inUnwrapped.additionalProperties as TSchema | undefined; + if (outVal && inVal) { + checkCompat(outVal, inVal, `${path}{}`, mismatches); + } + return; + } + + if (!schemasEqual(outUnwrapped, inUnwrapped) && !isSupertypeOf(inUnwrapped, outUnwrapped)) { + mismatches.push({ + path, + expected: typeName(inUnwrapped), + actual: typeName(outUnwrapped), + }); + } +} + +export function typeCompat(outputSchema: TSchema, inputSchema: TSchema): TypeCompatResult | undefined { + if (isUnknownType(outputSchema) || isUnknownType(inputSchema)) return undefined; + + const mismatches: TypeMismatch[] = []; + checkCompat(outputSchema, inputSchema, "", mismatches); + + if (mismatches.length === 0) { + const outUnwrapped = unwrapReadonlyOptional(outputSchema); + const inUnwrapped = unwrapReadonlyOptional(inputSchema); + if (KindGuard.IsObject(outUnwrapped) && KindGuard.IsObject(inUnwrapped)) { + const outProps = outUnwrapped.properties as Record | undefined; + const inProps = inUnwrapped.properties as Record | undefined; + if (outProps && inProps) { + const hasExtra = Object.keys(outProps).some((k) => !(k in inProps)); + if (hasExtra) { + return { compatible: true, detail: "output has extra fields beyond input requirements" }; + } + } + } + return { compatible: true }; + } + + return { compatible: false, mismatches }; +} \ No newline at end of file diff --git a/test/analysis/type-compat.test.ts b/test/analysis/type-compat.test.ts index 3078469..1d8b072 100644 --- a/test/analysis/type-compat.test.ts +++ b/test/analysis/type-compat.test.ts @@ -1,7 +1,414 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; +import { Type, type TSchema } from "@alkdev/typebox"; +import { typeCompat, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js"; -describe('analysis type-compat', () => { - it('placeholder', () => { - expect(true).toBe(true); +describe("typeCompat", () => { + describe("exact match", () => { + it("identical string schemas are compatible", () => { + const result = typeCompat(Type.String(), Type.String()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("identical number schemas are compatible", () => { + const result = typeCompat(Type.Number(), Type.Number()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("identical boolean schemas are compatible", () => { + const result = typeCompat(Type.Boolean(), Type.Boolean()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("identical object schemas are compatible", () => { + const output = Type.Object({ name: Type.String(), age: Type.Number() }); + const input = Type.Object({ name: Type.String(), age: Type.Number() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + }); + + describe("superset (output has extra fields)", () => { + it("output with extra fields is still compatible", () => { + const output = Type.Object({ name: Type.String(), age: Type.Number(), email: Type.String() }); + const input = Type.Object({ name: Type.String(), age: Type.Number() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + expect(result!.detail).toBe("output has extra fields beyond input requirements"); + }); + + it("output with many extra fields is compatible", () => { + const output = Type.Object({ a: Type.String(), b: Type.Number(), c: Type.Boolean(), d: Type.String() }); + const input = Type.Object({ a: Type.String() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + }); + + describe("subset (output missing required fields)", () => { + it("output missing a required field is incompatible", () => { + const output = Type.Object({ name: Type.String() }); + const input = Type.Object({ name: Type.String(), age: Type.Number() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + expect(result!.mismatches).toBeDefined(); + expect(result!.mismatches!.length).toBeGreaterThan(0); + const ageMismatch = result!.mismatches!.find((m) => m.path === "/age"); + expect(ageMismatch).toBeDefined(); + expect(ageMismatch!.expected).toBe("number"); + expect(ageMismatch!.actual).toBe("missing"); + }); + + it("output missing multiple required fields reports all mismatches", () => { + const output = Type.Object({ name: Type.String() }); + const input = Type.Object({ name: Type.String(), age: Type.Number(), email: Type.String() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + const paths = result!.mismatches!.map((m) => m.path); + expect(paths).toContain("/age"); + expect(paths).toContain("/email"); + }); + }); + + describe("type mismatch", () => { + it("string output vs number input is incompatible", () => { + const output = Type.Object({ value: Type.String() }); + const input = Type.Object({ value: Type.Number() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + const mismatch = result!.mismatches!.find((m) => m.path === "/value"); + expect(mismatch).toBeDefined(); + expect(mismatch!.expected).toBe("number"); + expect(mismatch!.actual).toBe("string"); + }); + + it("boolean output vs string input is incompatible", () => { + const output = Type.Object({ flag: Type.Boolean() }); + const input = Type.Object({ flag: Type.String() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + }); + }); + + describe("unknown passthrough", () => { + it("output is Type.Unknown() returns undefined", () => { + const result = typeCompat(Type.Unknown(), Type.String()); + expect(result).toBeUndefined(); + }); + + it("input is Type.Unknown() returns undefined", () => { + const result = typeCompat(Type.String(), Type.Unknown()); + expect(result).toBeUndefined(); + }); + + it("both schemas are Type.Unknown() returns undefined", () => { + const result = typeCompat(Type.Unknown(), Type.Unknown()); + expect(result).toBeUndefined(); + }); + }); + + describe("nested objects (recursive)", () => { + it("deeply nested objects with exact match are compatible", () => { + const output = Type.Object({ + address: Type.Object({ + city: Type.String(), + zip: Type.String(), + }), + }); + const input = Type.Object({ + address: Type.Object({ + city: Type.String(), + zip: Type.String(), + }), + }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("nested object with superset fields is compatible", () => { + const output = Type.Object({ + address: Type.Object({ + city: Type.String(), + zip: Type.String(), + country: Type.String(), + }), + }); + const input = Type.Object({ + address: Type.Object({ + city: Type.String(), + zip: Type.String(), + }), + }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("nested object missing required field is incompatible", () => { + const output = Type.Object({ + address: Type.Object({ + city: Type.String(), + }), + }); + const input = Type.Object({ + address: Type.Object({ + city: Type.String(), + zip: Type.String(), + }), + }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + const mismatch = result!.mismatches!.find((m) => m.path === "/address/zip"); + expect(mismatch).toBeDefined(); + }); + + it("nested object with type mismatch reports correct path", () => { + const output = Type.Object({ + address: Type.Object({ + city: Type.Number(), + }), + }); + const input = Type.Object({ + address: Type.Object({ + city: Type.String(), + }), + }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + const mismatch = result!.mismatches!.find((m) => m.path === "/address/city"); + expect(mismatch).toBeDefined(); + expect(mismatch!.expected).toBe("string"); + expect(mismatch!.actual).toBe("number"); + }); + }); + + describe("arrays", () => { + it("identical array element types are compatible", () => { + const result = typeCompat(Type.Array(Type.String()), Type.Array(Type.String())); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("array with incompatible element types is not compatible", () => { + const result = typeCompat(Type.Array(Type.Number()), Type.Array(Type.String())); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + expect(result!.mismatches!.length).toBeGreaterThan(0); + }); + + it("output array with union element type is not compatible with string array input", () => { + const result = typeCompat( + Type.Array(Type.Union([Type.String(), Type.Number()])), + Type.Array(Type.String()), + ); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + }); + + it("output array with string element is compatible with union array input", () => { + const result = typeCompat( + Type.Array(Type.String()), + Type.Array(Type.Union([Type.String(), Type.Number()])), + ); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + }); + + describe("optional fields", () => { + it("optional field in input that is present in output is compatible", () => { + const output = Type.Object({ name: Type.String(), email: Type.String() }); + const input = Type.Object({ name: Type.String(), email: Type.Optional(Type.String()) }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("optional field in input that is absent in output is compatible", () => { + const output = Type.Object({ name: Type.String() }); + const input = Type.Object({ name: Type.String(), email: Type.Optional(Type.String()) }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("required field in input that is absent in output is not compatible", () => { + const output = Type.Object({ name: Type.String() }); + const input = Type.Object({ name: Type.String(), age: Type.Number() }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + }); + }); + + describe("union types (subtype checking)", () => { + it("string output is compatible with string|number input", () => { + const result = typeCompat(Type.String(), Type.Union([Type.String(), Type.Number()])); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("string|number output is NOT compatible with string input", () => { + const result = typeCompat(Type.Union([Type.String(), Type.Number()]), Type.String()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + }); + + it("number output is compatible with string|number input", () => { + const result = typeCompat(Type.Number(), Type.Union([Type.String(), Type.Number()])); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("literal output is compatible with matching union input", () => { + const result = typeCompat(Type.Literal("hello"), Type.Union([Type.Literal("hello"), Type.Literal("world")])); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("literal output is NOT compatible with non-matching union input", () => { + const result = typeCompat(Type.Literal("other"), Type.Union([Type.Literal("hello"), Type.Literal("world")])); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + }); + }); + + describe("literal types", () => { + it("identical string literals are compatible", () => { + const result = typeCompat(Type.Literal("hello"), Type.Literal("hello")); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("different string literals are not compatible", () => { + const result = typeCompat(Type.Literal("hello"), Type.Literal("world")); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + }); + + it("string literal output is compatible with string input", () => { + const result = typeCompat(Type.Literal("hello"), Type.String()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("number literal output is compatible with number input", () => { + const result = typeCompat(Type.Literal(42), Type.Number()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + }); + + describe("integer and number compatibility", () => { + it("integer output is compatible with number input", () => { + const result = typeCompat(Type.Integer(), Type.Number()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("number output is NOT compatible with integer input", () => { + const result = typeCompat(Type.Number(), Type.Integer()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + }); + }); + + describe("complex realistic schemas", () => { + it("realistic operation schemas - classify output compatible with enrich input", () => { + const classifyOutput = Type.Object({ + categories: Type.Array(Type.String()), + confidence: Type.Number(), + metadata: Type.Optional(Type.Object({ + model: Type.String(), + version: Type.String(), + })), + }); + const enrichInput = Type.Object({ + categories: Type.Array(Type.String()), + confidence: Type.Number(), + }); + const result = typeCompat(classifyOutput, enrichInput); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + }); + + it("realistic operation schemas - incompatible due to field type", () => { + const fetchOutput = Type.Object({ + items: Type.Array(Type.Object({ id: Type.String(), name: Type.String() })), + total: Type.Number(), + }); + const processInput = Type.Object({ + items: Type.Array(Type.Object({ id: Type.Number(), name: Type.String() })), + total: Type.Number(), + }); + const result = typeCompat(fetchOutput, processInput); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + const mismatch = result!.mismatches!.find((m) => m.path.includes("id")); + expect(mismatch).toBeDefined(); + }); + + it("multiple mismatches at different depths", () => { + const output = Type.Object({ + name: Type.Number(), + address: Type.Object({ + city: Type.Number(), + zip: Type.Boolean(), + }), + }); + const input = Type.Object({ + name: Type.String(), + address: Type.Object({ + city: Type.String(), + zip: Type.String(), + }), + }); + const result = typeCompat(output, input); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + expect(result!.mismatches!.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe("TypeCompatResult shape", () => { + it("compatible result has no mismatches", () => { + const result = typeCompat(Type.String(), Type.String()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(true); + expect(result!.mismatches).toBeUndefined(); + }); + + it("incompatible result has mismatches array", () => { + const result = typeCompat(Type.String(), Type.Number()); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + expect(Array.isArray(result!.mismatches)).toBe(true); + expect(result!.mismatches!.length).toBeGreaterThan(0); + }); + + it("TypeMismatch has correct shape", () => { + const result = typeCompat( + Type.Object({ value: Type.String() }), + Type.Object({ value: Type.Number() }), + ); + expect(result).toBeDefined(); + expect(result!.compatible).toBe(false); + const mismatch: TypeMismatch = result!.mismatches![0]!; + expect(typeof mismatch.path).toBe("string"); + expect(typeof mismatch.expected).toBe("string"); + expect(typeof mismatch.actual).toBe("string"); + }); }); }); \ No newline at end of file