Implement typeCompat function for deep structural schema compatibility checking

This commit is contained in:
2026-05-21 21:05:50 +00:00
parent cbd26688f8
commit a060858e6e
3 changed files with 690 additions and 6 deletions

View File

@@ -1 +1,278 @@
export {};
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<typeof Kind, unknown>)[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<string, unknown>)["type"] === "object"
? schema
: ((schema as unknown as Record<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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<typeof Kind, unknown>)[Kind];
const bKind = (b as unknown as Record<typeof Kind, unknown>)[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<string, TSchema> | undefined;
const inProps = inUnwrapped.properties as Record<string, TSchema> | 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<string, TSchema> | undefined;
const inProps = inUnwrapped.properties as Record<string, TSchema> | 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 };
}