From b5e99ac67bf60d232020a7bf021576e438d1cd0f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 13:13:35 -0700 Subject: [PATCH] Name the deepest failing path when a checked cast fails inside an optional record A checked cast such as `JSON.parse(text) as Partial` makes every member an `X | undefined` union. When a present object failed X, the error named only the union's own path ("expected object | undefined at $.order, got object"), not the missing or mistyped member inside it. When no arm of a union matches and the value is the one container kind exactly one arm can take (an object for the union's only record arm, an array for its only array arm, beside unit and scalar arms), the failure block now runs that arm's builder, which throws at the deepest failing path with its reason ("expected string at $.order.owner.id, got number"). Other values keep the union's message. Only the failure block of union dynCheck builders changes; the matching path is untouched. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/dyn.ts | 43 +++++++++++++++++++++++ tests/harness/dyncheck.test.ts | 40 +++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 731385825..9769bdb31 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -39,6 +39,7 @@ import { isDirentShape, isDynTypedRefType, isRefCounted, + isUnitType, typeKey, } from "../../ir/ir.js"; import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js"; @@ -697,6 +698,46 @@ export class LlDyn { /** Emit the standard in-helper pending check: on a pending exception * run `cleanup` and return `dummy`. */ + /** The failure block of a union dynCheck whose value matched no arm. A + * value of the one container kind exactly one arm can take (an object + * for the union's only record arm, an array for its only array arm, + * beside unit and scalar arms) runs that arm's builder, which throws the + * deepest failing path and the reason there (`expected string at + * $.order.owner.id, got undefined`) instead of the union's own outer + * path. Other values, and an arm builder that unexpectedly succeeds, + * fall through to the union's message. Only the failure path changes. */ + private unionArmFailure(B: BlockBuilder, def: IrUnionDef, kind: string): void { + const scalar = (arm: IrType): boolean => + isUnitType(arm) || + arm.kind === "f64" || + arm.kind === "bool" || + arm.kind === "string" || + arm.kind === "bigint"; + const containers = def.arms.filter((arm) => !scalar(arm)); + const arm = containers.length === 1 ? containers[0]! : null; + const container = + arm?.kind === "array" + ? DYN_KIND.ARR + : arm?.kind === "record" && !this.host.recordsById.get(arm.shapeId)?.tuple + ? DYN_KIND.OBJ + : null; + if (!arm || container === null) return; + const host = this.host; + const deep = B.newLabel("dcu.deep"); + const outer = B.newLabel("dcu.outer"); + const same = B.tmp(); + B.line(`${same} = icmp eq i32 ${kind}, ${container}`); + B.condBr(same, deep, outer); + B.startBlock(deep); + const built = B.tmp(); + B.line(`${built} = call ptr @${this.dynCheckHelper(arm)}(ptr %d, ptr %path)`); + this.pendingBail(B, "dcu.deep", () => {}, "ptr null"); + const rc = vAdapters(host, arm); + B.line(`call void ${rc.release}(ptr ${built})`); + B.br(outer); + B.startBlock(outer); + } + private pendingBail(B: BlockBuilder, hint: string, cleanup: () => void, dummy: string): void { const p = B.tmp(); for (const line of this.host.pendingTestLines(p)) B.line(line.trimStart()); @@ -2291,6 +2332,7 @@ export class LlDyn { B.line(`call void @scr_dyn_release_v(ptr ${materialized})`); B.terminate(`ret ptr ${checked}`); B.startBlock(lFail); + this.unionArmFailure(B, def, kind); B.line(`call void @scr_dyn_check_fail(ptr %path, ptr ${want}, ptr %d)`); B.terminate(`ret ptr null`); break; @@ -2359,6 +2401,7 @@ export class LlDyn { ); B.terminate(`ret ptr ${checked}`); B.startBlock(lFail); + this.unionArmFailure(B, def, kind); B.line(`call void @scr_dyn_check_fail(ptr %path, ptr ${want}, ptr %d)`); B.terminate(`ret ptr null`); break; diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 2ab323f37..6a2d4dc61 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -305,6 +305,46 @@ console.log("unreachable", c.host); ); }); + test("optional record member: the failure names the deepest path inside it", async () => { + // `Partial` makes every member an `X | undefined` union. A present + // object that fails X used to report only the union's own path + // ("expected object | undefined at $.order, got object"); the record + // arm's builder now reports where inside it the value fails. + const r = await compileAndRun( + "optional-record-deep", + `interface Order { + owner: { id: string; plan: { tier: string } }; + total: number; +} +interface Envelope { order: Order; tags?: string[] } +const texts = [ + '{"order":{"owner":{"id":"u1"},"total":3}}', + '{"order":{"owner":{"id":7,"plan":{"tier":"pro"}},"total":3}}', + '{"order":{"owner":{"id":"u1","plan":{"tier":"pro"}},"total":3},"tags":["a",2]}', + '{"order":5}', +]; +for (const text of texts) { + try { + const e = JSON.parse(text) as Partial; + console.log("ok", e.order?.owner.id); + } catch (err) { + if (err instanceof TypeError) console.log(err.message); + } +} +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "expected object at $.order.owner.plan, got undefined", + "expected string at $.order.owner.id, got number", + "expected string at $.tags[1], got number", + "expected object | undefined at $.order, got number", + "", + ].join("\n"), + ); + }); + test("JSON null matches nothing", async () => { const r = await compileAndRun( "null-value",