Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/compiler/src/frontend/lowering/union-retag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,3 +700,21 @@ describe("retag IR construction", () => {
).toThrow("incomplete union retag plan");
});
});

test("only a proven uninhabited arm can bypass an incompatible payload conversion", () => {
const from: IrUnionDef = { id: "u0", arms: [record("ok"), record("numberError")] };
const to: IrUnionDef = { id: "u1", arms: [record("ok"), record("stringError")] };
const plan = () =>
planUnionRetag(
from,
to,
() => undefined,
() => null,
);
expect(plan()).toBeNull();
from.uninhabited = [1];
expect(plan()).toEqual([
{ kind: "direct", route: { tag: 0, lift: { how: "copy" }, values: [] } },
{ kind: "trap" },
]);
});
2 changes: 1 addition & 1 deletion packages/compiler/src/frontend/lowering/union-retag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export function planUnionRetag(
}
// A checker-proven absent arm retains any exact representation, as
// before. Otherwise the narrowing path needs no structural conversion.
if (identity < 0 && trappable?.has(tag)) {
if (identity < 0 && (trappable?.has(tag) || from.uninhabited?.includes(tag))) {
result.push({ kind: "trap" });
continue;
}
Expand Down
70 changes: 61 additions & 9 deletions packages/compiler/src/frontend/type-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,12 @@ export class UnionRegistry {

/** 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 {
finalizeRecursive(
t: ts.Type,
arms: IrType[],
discriminant?: IrUnionDiscriminant,
uninhabited?: number[],
): string {
const id = this.recIds.get(t);
if (id === undefined)
throw new InternalCompilerError(
Expand All @@ -534,22 +539,28 @@ export class UnionRegistry {
const def = this.byId.get(id)!;
def.arms.push(...arms);
if (discriminant) def.discriminant = discriminant;
if (uninhabited) def.uninhabited = uninhabited;
this.pendingRec.delete(id);
this.armTags.delete(id);
this.revision++;
const key = JSON.stringify([arms.map(typeKey), discriminant]);
const key = JSON.stringify([arms.map(typeKey), discriminant, uninhabited]);
if (!this.byKey.has(key)) this.byKey.set(key, id);
}
return id;
}

/** Interns canonical arms and discriminator cases, returning their unionId. */
intern(arms: IrType[], discriminant?: IrUnionDiscriminant): string {
const key = JSON.stringify([arms.map(typeKey), discriminant]);
intern(arms: IrType[], discriminant?: IrUnionDiscriminant, uninhabited?: number[]): string {
const key = JSON.stringify([arms.map(typeKey), discriminant, uninhabited]);
let id = this.byKey.get(key);
if (id === undefined) {
id = `u${this.unions.length}`;
const def: IrUnionDef = { id, arms, ...(discriminant ? { discriminant } : {}) };
const def: IrUnionDef = {
id,
arms,
...(discriminant ? { discriminant } : {}),
...(uninhabited ? { uninhabited } : {}),
};
this.byKey.set(key, id);
this.byId.set(id, def);
this.unions.push(def);
Expand All @@ -559,7 +570,14 @@ export class UnionRegistry {

/** An exact arm transformation keeps the source's semantic variants. */
transform(source: IrUnionDef, arms: IrType[]): string {
return this.intern(arms, remapUnionDiscriminant(source, arms));
const uninhabited = arms.flatMap((arm, tag) =>
source.uninhabited?.some((index) => typeEquals(source.arms[index]!, arm)) ? [tag] : [],
);
return this.intern(
arms,
remapUnionDiscriminant(source, arms),
uninhabited.length ? uninhabited : undefined,
);
}

get(unionId: string): IrUnionDef | undefined {
Expand Down Expand Up @@ -3992,6 +4010,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
const sensitivityAtEntry = contextResolutions;
try {
const byKey = new Map<string, IrType>();
const inhabited = new Set<string>();
const recordParts: { source: ts.Type; mapped: IrType & { kind: "record" } }[] = [];
const substitutedUnions: IrUnionDef[] = [];
// The lib's `T extends readonly unknown[] | []` idiom (Promise.all,
Expand All @@ -4014,10 +4033,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// void stays VOID (the return-position mapping above).
if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) {
byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T);
inhabited.add(typeKey(UNDEFINED_T));
continue;
}
if (part.flags & ts.TypeFlags.Null) {
byKey.set(typeKey(NULL_T), NULL_T);
inhabited.add(typeKey(NULL_T));
continue;
}
const mapped = mapType(part, ctx);
Expand All @@ -4027,6 +4048,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
}
if (mapped.kind === "void") {
byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T);
inhabited.add(typeKey(UNDEFINED_T));
continue;
}
// A generic parameter can substitute a complete union into one
Expand All @@ -4038,10 +4060,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (!substituted?.arms.length || substituted.arms.some((arm) => arm.kind === "union"))
return null;
substitutedUnions.push(substituted);
for (const arm of substituted.arms) byKey.set(typeKey(arm), arm);
substituted.arms.forEach((arm, tag) => {
byKey.set(typeKey(arm), arm);
if (!substituted.uninhabited?.includes(tag)) inhabited.add(typeKey(arm));
});
continue;
}
byKey.set(typeKey(mapped), mapped);
if (mapped.kind !== "record" || !hasRequiredNeverProperty(part, ctx))
inhabited.add(typeKey(mapped));
if (mapped.kind === "record") recordParts.push({ source: part, mapped });
}
const arms = [...byKey.values()];
Expand Down Expand Up @@ -4096,6 +4123,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
return null;
}
arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
const impossible = arms.flatMap((arm, tag) => (inhabited.has(typeKey(arm)) ? [] : [tag]));
const uninhabited = impossible.length ? impossible : undefined;
const discriminant =
unionDiscriminant(recordParts, arms, ctx) ??
(substitutedUnions.length === 1
Expand All @@ -4108,16 +4137,39 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// same ts.Type answers differently per instantiation — so
// recursive generic-open unions stay fenced.
if (contextResolutions !== sensitivityAtEntry) return null;
return { kind: "union", unionId: unions.finalizeRecursive(widened, arms, discriminant) };
return {
kind: "union",
unionId: unions.finalizeRecursive(widened, arms, discriminant, uninhabited),
};
}
return { kind: "union", unionId: unions.intern(arms, discriminant) };
return { kind: "union", unionId: unions.intern(arms, discriminant, uninhabited) };
} finally {
unions.inProgress.delete(widened);
}
}
return null;
}

function hasRequiredNeverProperty(type: ts.Type, ctx: TypeMapperCtx): boolean {
if (!(type.flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection))) return false;
return ctx.checker.getPropertiesOfType(type).some((property) => {
if (
property.flags &
(ts.SymbolFlags.Optional | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)
)
return false;
let value = ctx.checker.getTypeOfSymbol(property);
if (value.flags & ts.TypeFlags.TypeParameter) {
const resolved = ctx.resolveTypeParamTs?.(value);
if (resolved) {
contextResolutions++;
value = resolved;
}
}
return ctx.checker.isNeverType(value);
});
}

/** Retain a literal discriminator before widening erases its values. A
* structural match alone is insufficient: `{ kind: "loop", body: ... }`
* also fits a smaller `{ kind: "empty" }` record. Choosing that layout
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/ir/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,7 @@ export interface IrUnionDef {
* unit kinds (undefinedT/nullT) are payload-less arms. */
arms: IrType[];
discriminant?: IrUnionDiscriminant;
uninhabited?: number[];
}

export interface IrFunction {
Expand Down
19 changes: 19 additions & 0 deletions packages/compiler/src/ir/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2114,3 +2114,22 @@ test("a caught-typed global records a module error only from a catch binding", (
'in main: assign to catch binding "%error" (frontend must reject)',
]);
});

test.each([-1, 0, 1.5, 99])("uninhabited union tags reject invalid index %s", (tag) => {
const mod = discriminatedModule();
mod.unions![0]!.uninhabited = [tag];
expect(
validateModule(mod).some((error) => error.message.includes("invalid uninhabited record tag")),
).toBe(true);
});

test("uninhabited union tags require distinct record arms", () => {
const mod = discriminatedModule();
mod.unions![0]!.uninhabited = [1];
expect(validateModule(mod)).toEqual([]);
expect(deserializeModule(serializeModule(mod))).toEqual(mod);
mod.unions![0]!.uninhabited = [1, 1];
expect(
validateModule(mod).some((error) => error.message.includes("invalid uninhabited record tag")),
).toBe(true);
});
10 changes: 10 additions & 0 deletions packages/compiler/src/ir/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,16 @@ export function validateModule(mod: IrModule): IrValidationError[] {
if (u.arms.length < 2) {
errors.push({ message: `union ${u.id}: fewer than 2 arms`, loc: noLoc });
}
const uninhabited = new Set<number>();
for (const tag of u.uninhabited ?? []) {
if (!Number.isInteger(tag) || u.arms[tag]?.kind !== "record" || uninhabited.has(tag)) {
errors.push({
message: `union ${u.id}: invalid uninhabited record tag ${tag}`,
loc: noLoc,
});
}
uninhabited.add(tag);
}
if (u.discriminant) {
const { field, cases } = u.discriminant;
const tags = new Set<number>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/result-never-widening.ts"
],
"diags": []
}
54 changes: 54 additions & 0 deletions packages/compiler/test/type-mapper-unions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ beforeAll(() => {
writeFileSync(
entry,
`
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type NumberError = Result<string, number>;
export type SharedLayout = Result<string, never> | { ok: false; error: number };
export type IntersectionNever = { ok: true; value: string } | ({ ok: false } & { error: never });
export type SuccessOnly = Result<string, never>;
export type FailureOnly = Result<never, string>;
export type OptionalNever = { ok: true; value: string } | { ok: false; error?: never };
export type NeverArray = { ok: true; value: string } | { ok: false; error: never[] };
export type NeverCallback = { ok: true; value: string } | { ok: false; error: () => never };
export type NeverGetter = { ok: true; value: string } | { ok: false; get error(): never };
export type Choice = { kind: "a" | "alias"; value: number } | { kind: "b"; text: string };
export type Other = { kind: "x"; value: number } | { kind: "y"; text: string };
export type Numeric = { kind: 0; value: number } | { kind: -2.5; text: string };
Expand Down Expand Up @@ -187,3 +197,47 @@ describe("checker and synthesized optional union identity", () => {
expect(original.arms.some((arm) => arm.kind === "undefinedT")).toBe(false);
});
});

describe("uninhabited record union members", () => {
test.each(["SuccessOnly", "FailureOnly", "IntersectionNever"])(
"%s marks only its impossible record",
(name) => {
const ctx = context();
const result = mapped(name, ctx);
const union = ctx.unions.get(result.unionId)!;
expect(union.arms).toHaveLength(2);
expect(union.uninhabited).toHaveLength(1);
const arm = union.arms[union.uninhabited![0]!]!;
expect(arm.kind).toBe("record");
if (arm.kind !== "record") return;
expect(ctx.shapes.get(arm.shapeId)!.fields.map((field) => field.name)).toEqual(
name === "FailureOnly" ? ["ok", "value"] : ["error", "ok"],
);
const optional = withUndefinedArm(result, ctx.unions)!;
expect(optional.kind).toBe("union");
if (optional.kind !== "union") return;
expect(ctx.unions.get(optional.unionId)!.uninhabited).toHaveLength(1);
},
);

test.each([true, false])(
"never and number contracts remain distinct (never first: %s)",
(first) => {
const ctx = context();
const names = first ? ["SuccessOnly", "NumberError"] : ["NumberError", "SuccessOnly"];
const [a, b] = names.map((name) => mapped(name, ctx));
expect(a!.unionId).not.toBe(b!.unionId);
expect(ctx.unions.get(mapped("NumberError", ctx).unionId)!.uninhabited).toBeUndefined();
},
);

test.each(["OptionalNever", "NeverArray", "NeverCallback", "NeverGetter", "SharedLayout"])(
"%s retains its inhabited alternative",
(name) => {
const ctx = context();
const result = mapped(name, ctx);
expect(ctx.unions.get(result.unionId)!.arms).toHaveLength(2);
expect(ctx.unions.get(result.unionId)!.uninhabited).toBeUndefined();
},
);
});
53 changes: 53 additions & 0 deletions tests/corpus/result-never-widening.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}

function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}

function parse(value: string): Result<string, string> {
return value.length > 0 ? ok(value) : err("empty");
}

function receive(value: Result<string, string>): void {
console.log(JSON.stringify(value));
}

receive(parse("hello"));
receive(parse(""));
receive(ok("argument"));
receive(err("argument-error"));

const numberResult: Result<number, string> = ok(42);
const objectResult: Result<{ name: string }, string> = ok({ name: "Ada" });
const arrayResult: Result<string[], string> = ok(["a", "b"]);
const errorResult: Result<string, { code: number }> = err({ code: 7 });
console.log(JSON.stringify(numberResult));
console.log(JSON.stringify(objectResult));
console.log(JSON.stringify(arrayResult));
console.log(JSON.stringify(errorResult));

function wrapped(): { result: Result<string, string> } {
const source = { result: ok("nested") };
return source;
}
console.log(JSON.stringify(wrapped()));

const source: Result<string, never>[] = [ok("first"), ok("second")];
const destination: Result<string, string>[] = source;
console.log(JSON.stringify(destination));

type OptionalResult = { ok: true; value: string } | { ok: false; error?: never };
function optionalResult(): OptionalResult {
return { ok: false };
}
console.log(JSON.stringify(optionalResult()));

function unwrap(result: Result<string, never>): string {
if (result.ok) return result.value;
throw result.error;
}
console.log(unwrap(ok("unreachable error")));