Map intersections of equally represented constituents as that type (#2228)

This commit is contained in:
Chris Tate
2026-10-25 13:11:01 -05:00
committed by GitHub
4 changed files with 115 additions and 0 deletions
@@ -4948,6 +4948,19 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
const record = mapRecordType(widened, ctx);
if (record) return record;
}
// An intersection whose constituents all map to ONE representation is
// that representation. An intersection that restates an optional member
// as required (`Opts & { files: Array<{ path: string }> }` over
// `files?: Array<{ path: string }>`) gives the member the type `A[] & B[]`
// with two separately written but structurally equal element literals;
// the checker keeps both, while their arrays (tuples, maps, ...) intern
// to one IR type. Reached only after every other intersection rule
// declined, so intersections that already mapped keep their mapping. A
// dynamic result stays unmapped: two opaque types are not one type.
if (widened.isIntersectionType()) {
const same = mapEqualConstituentsIntersection(widened, ctx);
if (same) return same;
}
// Unions. `boolean` is internally `true | false`, and narrowing produces
// literal unions like `"a" | "b"` — parts that map to the SAME IR type
// collapse (deduplicated by typeKey), so a single surviving arm is just
@@ -6813,6 +6826,19 @@ export function withUnitArm(
* assigned keys, which is also Node's answer for Object.assign(fn, obj)
* (a function's own length/name are non-enumerable). Null whenever the
* shape doesn't fit — the caller falls through to the plain-func mapping. */
/** The IR type every constituent of an intersection maps to, or null when a
* constituent is unmappable or dynamic, or two constituents differ. */
function mapEqualConstituentsIntersection(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
let same: IrType | null = null;
for (const part of ts.constituentTypes(widened)) {
const mapped = mapType(part, ctx);
if (mapped === null || mapped.kind === "dyn") return null;
if (same === null) same = mapped;
else if (!typeEquals(same, mapped)) return null;
}
return same;
}
function mapHybridCallableIntersection(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
const { checker, shapes } = ctx;
if (!widened.isIntersectionType()) return null;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/intersection-restated-members.ts"
],
"diags": []
}
@@ -0,0 +1,82 @@
// An intersection that restates a member with a separately written but
// structurally equal type maps as that one type. Restating an optional member
// as required makes it required; the checker keeps both element literals
// (`A[] & B[]`), and their arrays, tuples, and maps share one representation.
interface Options {
root: string;
files?: Array<{ path: string; size: number }>;
}
type Normalized = Options & { files: Array<{ path: string; size: number }> };
function normalize(options: Options): Normalized {
return { ...options, files: options.files ?? [] };
}
function totalSize(n: Normalized): number {
let total = 0;
for (const f of n.files) total += f.size;
return total;
}
const plain = normalize({ root: "/a" });
console.log(plain.root, plain.files.length, totalSize(plain));
const full = normalize({
root: "/b",
files: [
{ path: "x.txt", size: 3 },
{ path: "y.txt", size: 4 },
],
});
full.files.push({ path: "z.txt", size: 5 });
console.log(full.files.map((f) => f.path).join(","), totalSize(full));
console.log(JSON.stringify(full));
let maybe: Normalized | undefined;
if (process.argv.length < 99) maybe = { root: "/", files: [{ path: "a", size: 1 }] };
console.log(maybe?.root, maybe?.files[0]?.path);
// Nested records whose inner arrays are restated.
interface Settings {
name: string;
meta?: { tags: Array<{ key: string; value: string }> };
}
type Resolved = Settings & { meta: { tags: Array<{ key: string; value: string }> } };
const resolved: Resolved = { name: "s", meta: { tags: [{ key: "k", value: "v" }] } };
resolved.meta.tags.push({ key: "k2", value: "v2" });
for (const t of resolved.meta.tags) console.log(`${t.key}=${t.value}`);
// Tuples, nested arrays, and maps.
interface Layout {
pair?: [{ x: number }, string];
grid?: Array<Array<{ cell: number }>>;
index?: Map<string, { hits: number }>;
}
type Filled = Layout & {
pair: [{ x: number }, string];
grid: Array<Array<{ cell: number }>>;
index: Map<string, { hits: number }>;
};
const filled: Filled = {
pair: [{ x: 7 }, "seven"],
grid: [[{ cell: 1 }, { cell: 2 }], [{ cell: 3 }]],
index: new Map([["home", { hits: 2 }]]),
};
filled.index.set("about", { hits: 1 });
console.log(filled.pair[0].x, filled.pair[1]);
console.log(filled.grid.map((row) => row.map((c) => c.cell).join("+")).join(" | "));
for (const [k, v] of filled.index) console.log(k, v.hits);
// Both sides required, and both sides optional (the member stays optional).
type Twice = { items: Array<{ n: number }> } & { items: Array<{ n: number }> };
const twice: Twice = { items: [{ n: 1 }, { n: 2 }] };
console.log(twice.items.reduce((s, i) => s + i.n, 0));
type BothOptional = { extra?: Array<{ n: number }> } & { extra?: Array<{ n: number }> };
const none: BothOptional = {};
const some: BothOptional = { extra: [{ n: 9 }] };
console.log(none.extra === undefined, some.extra?.[0]?.n);
// A restated member read through a function value.
type Handler = (event: Normalized) => string;
const describe: Handler = (event) => `${event.root}:${event.files.length}`;
console.log(describe(full), describe(plain));
@@ -0,0 +1 @@
{ "plain": 4000 }