Keep the whole union of an optional parameter that reaches a recursive placeholder (#2241)

This commit is contained in:
Chris Tate
2026-10-25 17:10:27 -05:00
committed by GitHub
4 changed files with 133 additions and 2 deletions
+19 -2
View File
@@ -717,6 +717,12 @@ export class UnionRegistry {
return id !== undefined && this.pendingRec.has(id);
}
/** True when `unionId` is a recursive placeholder whose arms are not
* known yet (its outer frame is still mapping, or failed). */
isPendingPlaceholder(unionId: string): boolean {
return this.pendingRec.has(unionId);
}
/** Completes a recursive placeholder with its canonical arm list and
* registers the structural key (first writer wins, like shapes). */
finalizeRecursive(t: ts.Type, arms: IrType[], discriminant?: IrUnionDiscriminant): string {
@@ -5335,8 +5341,19 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null
if (pt) pt = jsOpenObjectType(decl, pt, ctx.shapes, ctx.unions);
// Belt and braces for non-strict type worlds: an optional param's
// ABI slot is always the undefined-armed union (strictNullChecks
// already spells it that way; arm it here if the world didn't).
if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn") {
// already spells it that way; arm it here if the world didn't). A
// back-reference to a union still being mapped (`options?: { then?:
// (ctx: Sub) => void }` where Sub extends the interface declaring the
// method) is a recursive placeholder with no arms yet; when the checker
// spelled the parameter `X | undefined`, the placeholder receives that
// undefined arm when its frame finishes.
const pendingOptional =
pt?.kind === "union" &&
ctx.unions.isPendingPlaceholder(pt.unionId) &&
ts
.constituentTypes(checker.getTypeOfSymbol(p))
.some((part) => (part.flags & ts.TypeFlags.Undefined) !== 0);
if (optional && pt && pt.kind !== "void" && pt.kind !== "dyn" && !pendingOptional) {
const armed = withUndefinedArm(pt, ctx.unions);
if (!armed) return null;
pt = armed;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/recursive-optional-callback-params.ts"
],
"diags": []
}
@@ -0,0 +1,107 @@
// An optional parameter whose type reaches back to the interface that
// declares the method keeps its whole union. A command context's
// `newSession(options?)` takes callbacks typed by a subinterface of the
// context, so mapping the context passes through the options union while
// that union is still being mapped; the parameter still holds the options
// record, not only `undefined`.
interface CommandContext {
cwd: string;
newSession(options?: {
parent?: string;
withSession?: (ctx: ReplacedContext) => Promise<void>;
}): Promise<{ cancelled: boolean }>;
fork(
entryId: string,
options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedContext) => Promise<void> },
): Promise<{ cancelled: boolean }>;
}
interface ReplacedContext extends CommandContext {
depth: number;
send(content: string): Promise<void>;
}
interface Command {
description?: string;
handler: (args: string, ctx: CommandContext) => Promise<void>;
}
const log: string[] = [];
const commands = new Map<string, Command>();
function register(name: string, command: Command): void {
commands.set(name, command);
}
register("new", {
description: "start a session",
handler: async (args, ctx) => {
const first = await ctx.newSession({
parent: args,
withSession: async (next) => {
await next.send(`hello from ${next.cwd} at depth ${next.depth}`);
const nested = await next.newSession({
withSession: async (inner) => {
await inner.send(`nested at depth ${inner.depth}`);
},
});
log.push(`nested cancelled=${nested.cancelled}`);
},
});
log.push(`first cancelled=${first.cancelled}`);
const bare = await ctx.newSession();
log.push(`bare cancelled=${bare.cancelled}`);
},
});
register("fork", {
handler: async (args, ctx) => {
const r = await ctx.fork(args, {
position: "at",
withSession: async (next) => next.send(`forked ${args} at depth ${next.depth}`),
});
log.push(`fork cancelled=${r.cancelled}`);
const plain = await ctx.fork(args);
log.push(`plain fork cancelled=${plain.cancelled}`);
},
});
function makeContext(depth: number): ReplacedContext {
return {
cwd: `/work/${depth}`,
depth,
newSession: async (options) => {
log.push(`newSession depth=${depth} parent=${options?.parent ?? "-"}`);
if (options?.withSession) {
await options.withSession(makeContext(depth + 1));
return { cancelled: false };
}
return { cancelled: true };
},
fork: async (entryId, options) => {
log.push(`fork ${entryId} position=${options?.position ?? "-"}`);
const callback = options?.withSession;
if (callback === undefined) return { cancelled: true };
await callback(makeContext(depth + 1));
return { cancelled: false };
},
send: async (content) => {
log.push(`send: ${content}`);
},
};
}
async function run(): Promise<void> {
const root: CommandContext = makeContext(0);
for (const [name, command] of commands) {
log.push(`run ${name} (${command.description ?? "no description"})`);
await command.handler(`entry-${name}`, root);
}
for (const line of log) console.log(line);
}
run().catch((e: unknown) => {
console.log("failed", e instanceof Error ? e.message : String(e));
process.exitCode = 1;
});
@@ -0,0 +1 @@
{ "plain": 6000 }