Lower chain-guarded union reads from the stored union

Inside an optional-chain body the checker types the guarded receiver by its
non-nullish narrowing, often one record of a discriminated union, while the
bound chain value still carries the whole present union: a runtime-optional
local that crossed an `await` and a prior `stored?.type === "oauth"` return
holds `ApiKeyCredential | OAuthCredential` at runtime.

lowerUnionProperty gated on the spelled receiver type being a union, so the
read fell through to the generic SC1090 fence ("reading 'type' from a value
of type 'ApiKeyCredential'"). Take the union from the chain binding, which is
the value lowerExpr is about to read; unionDisc then dispatches on the stored
tag and maybeNarrow applies the checker's narrowing to the result.

Adds the differential corpus program union-read-after-chain-guard and its
emission test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:13 -07:00
co-authored by Claude Opus 5.5
parent 1ed5ba53aa
commit 04409081f9
5 changed files with 167 additions and 1 deletions
@@ -3545,9 +3545,21 @@ export function lowerUnionProperty(
expr: ts.PropertyAccessExpression,
): IrExpr | null {
if (lowerer.chainBlocked(expr)) return null;
const receiverIr =
const spelled =
structuralReadType(lowerer, expr.expression) ??
lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
// Inside an optional-chain body the guarded receiver is checker-typed by
// its non-nullish narrowing — often one record of a discriminated union
// — while the bound chain value still carries the whole present union
// (`stored?.type` after an earlier `stored?.type === "oauth"` return
// narrowed `stored` to `ApiKeyCredential`, though the runtime-optional
// storage still holds `ApiKeyCredential | OAuthCredential`). The union
// reader dispatches on the runtime tag, so take the union the chain
// binding hands to `lowerExpr` below instead of the spelled narrowing.
const chainRecv = lowerer.chainRecvByNode.get(expr.expression);
const chainUnion =
spelled?.kind !== "union" && chainRecv?.type.kind === "union" ? chainRecv.type : null;
const receiverIr = chainUnion ?? spelled;
// The checker can rule the receiver UNREACHABLE and the assertion that
// escapes it (`const x: never = event; (event as any).type` — the
// `default:` branch of a switch that exhausts every arm) erases to
@@ -3581,6 +3593,16 @@ export function lowerUnionProperty(
// extract the instance and test its class, as typed slots do.
else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value;
}
// The spelled type is a narrowing, so every path below (dyn, record,
// object, array) was unreachable for this read before it took the stored
// union: answer it only when the union reader can, and otherwise hand the
// read back to the lowering the spelled type used to reach. A stored arm
// the checker ruled out that has no such member (`m1?.name` where the
// stored union still carries a `string` arm) must not turn an accepted
// program into a refusal.
if (chainUnion !== null) {
return value.type.kind === "union" ? lowerUnionValueRead(lowerer, expr, value) : null;
}
// A checker-union receiver whose VALUE lowered to a plain RECORD (the
// merged-signature fiction — `runner(cmd, args)` where runner joined
// a structural runner type with spawnSync's, and the local adopted
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-read-after-chain-guard.ts"
],
"diags": []
}
@@ -0,0 +1,89 @@
// A member read on a union receiver inside an optional-chain body: the
// checker narrows the guarded receiver to ONE union arm (a discriminant
// test through `?.`), while the bound chain value still carries the whole
// present union — the runtime-optional local a prior `if (s?.kind === ...)
// return` narrowed after an `await`. The read must dispatch on the value's
// runtime tag (unionDisc), not fall through to the unsupported-property
// fence the spelled single-record type would suggest.
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
const PRELUDE = [
"interface ApiKeyCredential { type: 'api_key'; key?: string }",
"interface OAuthCredential { type: 'oauth'; refresh: string; expires: number }",
"type Credential = ApiKeyCredential | OAuthCredential;",
];
type IrModule = {
entry: string;
functions: { name: string; body: unknown }[];
};
async function compileToIr(
body: string[],
): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-union-chain-read-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, [...PRELUDE, ...body].join("\n"));
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
return {
ok: false,
module: null,
diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
};
const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule;
expect(validateModule(module as never)).toEqual([]);
return { ok: true, module, diagnostics: "" };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The IR bodies of the functions whose name starts with `root`, as text
* (a specialized function keeps its source name as a prefix). */
function bodies(module: IrModule, root: string): string {
return JSON.stringify(
module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body),
);
}
test("a chain-guarded union read dispatches on the stored union", async () => {
const compiled = await compileToIr([
"function load(): Promise<Credential | undefined> {",
" return Promise.resolve({ type: 'api_key', key: 'k' } as Credential);",
"}",
"async function pick(): Promise<string> {",
" const stored = await load();",
" if (stored?.type === 'oauth') return stored.expires > 0 ? 'oauth' : 'expired';",
" const credential = stored?.type === 'api_key' ? stored : undefined;",
" return credential?.type === 'api_key' ? (credential.key ?? 'none') : 'missing';",
"}",
"pick().then((answer) => console.log(answer));",
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
// The guarded read still sees both stored arms; the tag switch is the
// shape that composes with the discriminant comparison.
const pick = bodies(compiled.module!, "pick");
expect(pick).toContain('"unionDisc"');
// The runtime-optional local crosses the await, so the presence test is
// the chain's own guard, not a checked extraction trap on every read.
expect(pick).not.toContain('"unsupported"');
});
test("a chain-guarded read of a non-discriminant field reads the survivor", async () => {
const compiled = await compileToIr([
"function only(c: Credential | undefined): string {",
" return c?.type === 'oauth' ? c.refresh : 'api';",
"}",
"console.log(only({ type: 'oauth', refresh: 'r', expires: 1 }));",
"console.log(only(undefined));",
]);
expect(compiled.ok, compiled.diagnostics).toBe(true);
expect(bodies(compiled.module!, "only")).toContain('"refresh"');
});
@@ -0,0 +1,48 @@
// A discriminated union held across an `await`: the early `stored?.type ===
// "oauth"` return narrows the checker's `stored` to the api-key arm, but the
// runtime-optional storage still holds both arms. The later `s?.type` reads
// dispatch on the stored value's tag, so every credential shape answers as in
// Node — including the arm the earlier guard ruled out at runtime.
type ProviderEnv = Record<string, string>;
interface ApiKeyCredential {
type: "api_key";
key?: string;
env?: ProviderEnv;
}
interface OAuthCredentials {
refresh: string;
access: string;
expires: number;
[key: string]: unknown;
}
interface OAuthCredential extends OAuthCredentials {
type: "oauth";
}
type Credential = ApiKeyCredential | OAuthCredential;
const stored: (Credential | undefined)[] = [
{ type: "api_key", key: "sk-live", env: { REGION: "eu" } },
{ type: "oauth", refresh: "r1", access: "a1", expires: 42 },
{ type: "api_key" },
undefined,
];
function load(i: number): Promise<Credential | undefined> {
return Promise.resolve(stored[i]);
}
async function describe(i: number): Promise<string> {
const found = await load(i);
if (found?.type === "oauth") {
return found.expires > 10 ? `oauth ${found.refresh}/${found.access}` : "oauth expired";
}
const credential = found?.type === "api_key" ? found : undefined;
const key = credential?.type === "api_key" ? (credential.key ?? "<none>") : "<missing>";
const region = credential?.env?.REGION ?? "<no-env>";
return `api ${key} ${region}`;
}
async function main(): Promise<void> {
for (let i = 0; i < stored.length; i++) console.log(i, await describe(i));
}
main();
@@ -0,0 +1 @@
{ "plain": 2200, "san": 2300 }