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 }; }