Compare canonical types structurally and keep tuple flags in the normalizer

- The intern key names bound type parameters by identity, not by name, so
  environments that bind two parameters called T to swapped types no
  longer share a node.
- Type equality (intersection constituents, union arms) is a structural
  comparison, canonEqual, that compares signature parameters, index and
  construct signatures at any depth and closes on recursive types.
  canonKey no longer serves as equality, and now prints parameter types,
  construct and index signatures.
- An object with index or construct signatures is not the empty object of
  a narrowed parameter, so a dictionary intersected with named properties
  keeps its index signature.
- Arrays and tuples enter the intern table before their elements are
  normalized, so recursive array and tuple aliases close on themselves.
- Tuple elements read their optional and rest flags from the tuple's
  element flags.
This commit is contained in:
Chris Tate
2026-10-25 22:52:33 -05:00
parent 53ebf05ff5
commit 43d207ce38
2 changed files with 241 additions and 20 deletions
+150 -20
View File
@@ -103,14 +103,119 @@ export function canonKey(c: Canon, depth = 0): string {
const props = c
.props()
.map((p) => `${p.name}${p.optional ? "?" : ""}: ${canonKey(p.type(), depth + 1)}`);
const calls = c.call().map((s) => `(${s.params.length}) => ${canonKey(s.ret(), depth + 1)}`);
return `{ ${[...calls, ...props].join("; ")} }`;
const sig = (s: CanonSig): string =>
`(${s.params
.map(
(p) => `${p.rest ? "..." : ""}${p.optional ? "?" : ""}${canonKey(p.type(), depth + 1)}`,
)
.join(", ")}) => ${canonKey(s.ret(), depth + 1)}`;
const calls = c.call().map(sig);
const constructs = c.construct().map((s) => `new ${sig(s)}`);
const index = c
.index()
.map((i) => `[${canonKey(i.key, depth + 1)}]: ${canonKey(i.value(), depth + 1)}`);
return `{ ${[...calls, ...constructs, ...index, ...props].join("; ")} }`;
}
}
}
/** Whether two canonical nodes are the same type, structurally: unlike
* canonKey, every part is compared (signature parameters, index
* signatures, any depth). A pair met again while it is being compared is
* taken as equal, which closes the comparison of recursive types. */
export function canonEqual(
a: Canon,
b: Canon,
assumed: Map<Canon, Set<Canon>> = new Map(),
): boolean {
if (a === b) return true;
if (a.k !== b.k) return false;
let pairs = assumed.get(a);
if (pairs?.has(b)) return true;
if (pairs === undefined) assumed.set(a, (pairs = new Set()));
pairs.add(b);
const eq = (x: Canon, y: Canon): boolean => canonEqual(x, y, assumed);
const sigsEqual = (xs: readonly CanonSig[], ys: readonly CanonSig[]): boolean =>
xs.length === ys.length &&
xs.every((x, i) => {
const y = ys[i]!;
return (
x.generic === y.generic &&
x.params.length === y.params.length &&
x.params.every(
(p, j) =>
p.optional === y.params[j]!.optional &&
p.rest === y.params[j]!.rest &&
eq(p.type(), y.params[j]!.type()),
) &&
eq(x.ret(), y.ret())
);
});
switch (a.k) {
case "prim":
return a.p === (b as typeof a).p;
case "lit":
return a.value === (b as typeof a).value;
case "unit":
return a.u === (b as typeof a).u;
case "top":
return a.t === (b as typeof a).t;
case "param":
return a.name === (b as typeof a).name;
case "opaque":
return a.ts === (b as typeof a).ts;
case "array": {
const o = b as typeof a;
return a.readonly === o.readonly && eq(a.elem, o.elem);
}
case "tuple": {
const o = b as typeof a;
return (
a.elems.length === o.elems.length &&
a.elems.every(
(e, i) =>
e.optional === o.elems[i]!.optional &&
e.rest === o.elems[i]!.rest &&
eq(e.type, o.elems[i]!.type),
)
);
}
case "union": {
const o = b as typeof a;
return (
a.arms.length === o.arms.length &&
a.arms.every((x) => o.arms.some((y) => eq(x, y))) &&
o.arms.every((y) => a.arms.some((x) => eq(x, y)))
);
}
case "obj": {
const o = b as typeof a;
if (a.nominal !== o.nominal) return false;
if (a.nominal !== undefined) return true;
const ap = a.props();
const op = o.props();
if (ap.length !== op.length) return false;
for (const p of ap) {
const q = op.find((x) => x.name === p.name);
if (q === undefined || q.optional !== p.optional || !eq(p.type(), q.type())) return false;
}
const ai = a.index();
const oi = o.index();
return (
ai.length === oi.length &&
ai.every((x, i) => eq(x.key, oi[i]!.key) && eq(x.value(), oi[i]!.value())) &&
sigsEqual(a.call(), o.call()) &&
sigsEqual(a.construct(), o.construct())
);
}
}
}
export class Normalizer {
private readonly interned = new Map<string, Canon>();
/** A number for each type parameter an environment binds: parameters
* with the same name are distinct keys. */
private readonly paramIds = new Map<ts.Symbol, number>();
constructor(private readonly checker: ts.TypeChecker) {}
@@ -129,8 +234,11 @@ export class Normalizer {
private envKey(env: CanonEnv): string {
if (env.size === 0) return "";
const parts: string[] = [];
for (const [param, bound] of env)
parts.push(`${ts.symbolName(param)}=${(bound as { id?: number }).id ?? "?"}`);
for (const [param, bound] of env) {
let id = this.paramIds.get(param);
if (id === undefined) this.paramIds.set(param, (id = this.paramIds.size));
parts.push(`${id}=${(bound as { id?: number }).id ?? "?"}`);
}
return parts.sort().join(",");
}
@@ -169,7 +277,6 @@ export class Normalizer {
/** Flat, deduplicated, never-free; `void` reads as `undefined`. */
private union(parts: readonly ts.Type[], env: CanonEnv): Canon {
const arms: Canon[] = [];
const seen = new Set<string | Canon>();
const add = (c: Canon): void => {
if (c.k === "union") {
for (const a of c.arms) add(a);
@@ -177,11 +284,9 @@ export class Normalizer {
}
if (c.k === "unit" && c.u === "never") return;
const arm: Canon = c.k === "unit" && c.u === "void" ? { k: "unit", u: "undefined" } : c;
// Objects dedupe by identity (they intern by checker type): their
// structural key would walk into a recursive type still being built.
const k = arm.k === "obj" ? arm : canonKey(arm);
if (seen.has(k)) return;
seen.add(k);
// Objects dedupe by identity (they intern by checker type): comparing
// their members would walk into a recursive type still being built.
if (arms.some((a) => (arm.k === "obj" ? a === arm : canonEqual(a, arm)))) return;
arms.push(arm);
};
for (const part of parts) add(this.canon(part, env));
@@ -206,12 +311,16 @@ export class Normalizer {
* primitive branded with an object is the primitive. */
private intersection(type: ts.Type, env: CanonEnv, key: string | null): Canon {
const parts = ts.constituentTypes(type).map((t) => this.canon(t, env));
const keys = new Set(parts.map((p) => canonKey(p)));
if (keys.size === 1) return parts[0]!;
if (parts.every((p) => canonEqual(p, parts[0]!))) return parts[0]!;
// Narrowed parameters: `T & {}` (and `T & ({} | null)`'s object part)
// keep T without its undefined and null arms.
const empty = (p: Canon): boolean =>
p.k === "obj" && p.nominal === undefined && p.props().length === 0 && p.call().length === 0;
p.k === "obj" &&
p.nominal === undefined &&
p.props().length === 0 &&
p.index().length === 0 &&
p.call().length === 0 &&
p.construct().length === 0;
const rest = parts.filter((p) => !empty(p));
if (rest.length === 1 && rest.length < parts.length) {
const only = rest[0]!;
@@ -228,21 +337,42 @@ export class Normalizer {
private object(type: ts.Type, env: CanonEnv, key: string | null): Canon {
const checker = this.checker;
// Arrays and tuples enter the intern table before their elements are
// normalized, so a recursive alias (`type Tree = Tree[] | number`)
// closes on the node under construction.
if (checker.isArrayType(type)) {
const [elem] = checker.getTypeArguments(type as ts.TypeReference);
return {
const node: Canon & { k: "array" } = {
k: "array",
elem: elem === undefined ? { k: "top", t: "unknown" } : this.canon(elem, env),
elem: { k: "top", t: "unknown" },
readonly:
type.getSymbol() !== undefined && ts.symbolName(type.getSymbol()!) === "ReadonlyArray",
};
if (key !== null) this.interned.set(key, node);
if (elem !== undefined) node.elem = this.canon(elem, env);
return node;
}
if (checker.isTupleType(type)) {
const args = checker.getTypeArguments(type as ts.TypeReference);
return {
k: "tuple",
elems: args.map((a) => ({ type: this.canon(a, env), optional: false, rest: false })),
};
const ref = type as ts.TupleTypeReference;
// The element flags live on the tuple shape: the type itself, or the
// target of a reference to it.
const shape =
(ref.elementFlags as ts.ElementFlags[] | undefined) !== undefined
? ref
: (ref.getTarget() as ts.TupleType | undefined);
const flags = shape?.elementFlags ?? [];
const elems: { type: Canon; optional: boolean; rest: boolean }[] = [];
const node: Canon = { k: "tuple", elems };
if (key !== null) this.interned.set(key, node);
checker.getTypeArguments(ref).forEach((a, i) => {
const f = flags[i] ?? ts.ElementFlags.Required;
elems.push({
type: this.canon(a, env),
optional: (f & ts.ElementFlags.Optional) !== 0,
rest: (f & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0,
});
});
return node;
}
const symbol = type.getSymbol();
const decl = symbol?.declarations[0];
@@ -12,6 +12,7 @@ let checker: ts.TypeChecker;
const aliases = new Map<string, ts.Type>();
const asTypes = new Map<string, { type: ts.Type; params: ts.Symbol[] }>();
const valueTypes = new Map<string, ts.Type>();
const typeParams = new Map<string, ts.Symbol>();
beforeAll(() => {
directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-canon-"));
@@ -45,6 +46,23 @@ beforeAll(() => {
export class Point { x = 0 }
export type Points = readonly Point[];
export type Pair = [string, number];
// Two type parameters named T, in different functions.
export function first<T>(x: unknown) {
return x as T[];
}
export function second<T>(x: unknown) {
return x as readonly T[];
}
// Distinct callables, and arrays of them.
export type Calls = ((a: string) => void) & ((a: number) => void);
export type CallArrays = ((a: string) => void)[] | ((a: number) => void)[];
// A dictionary with a named property.
export type Dict = { [key: string]: number } & { size: number };
// Recursive array and tuple aliases.
export type Nested = number | Nested[];
export type Chain = [number, Chain] | null;
// Optional and rest tuple elements.
export type Loose = [string, number?, ...boolean[]];
`,
);
load = loadProgram(entry);
@@ -67,6 +85,11 @@ beforeAll(() => {
params,
});
}
if (ts.isTypeParameterDeclaration(node) && ts.isFunctionDeclaration(node.parent))
typeParams.set(
`${node.parent.name?.text}.${node.name.text}`,
checker.getSymbolAtLocation(node.name)!,
);
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name))
valueTypes.set(node.name.text, checker.getTypeAtLocation(node.name));
if (
@@ -152,6 +175,74 @@ describe("canon", () => {
);
});
test("type parameters with the same name are distinct in the intern key", () => {
const n = new Normalizer(checker);
const { type } = asTypes.get("T[]")!;
const firstT = typeParams.get("first.T")!;
const secondT = typeParams.get("second.T")!;
const num = valueTypes.get("sampleNumber")!;
const text = valueTypes.get("sampleText")!;
expect(
key(
n.canon(
type,
new Map([
[firstT, num],
[secondT, text],
]),
),
),
).toBe("number[]");
expect(
key(
n.canon(
type,
new Map([
[firstT, text],
[secondT, num],
]),
),
),
).toBe("string[]");
});
test("callables and arrays of callables with different parameters stay distinct", () => {
const n = new Normalizer(checker);
const calls = n.canon(aliases.get("Calls")!);
expect(calls.k === "obj" && calls.call().length).toBe(2);
const arms = n.canon(aliases.get("CallArrays")!);
expect(arms.k === "union" && arms.arms.length).toBe(2);
expect(key(arms)).toBe("{ (string) => void }[] | { (number) => void }[]");
});
test("intersecting a dictionary with a named property keeps the index signature", () => {
const dict = new Normalizer(checker).canon(aliases.get("Dict")!);
expect(key(dict)).toBe("{ [string]: number; size: number }");
});
test("recursive array and tuple aliases close on themselves", () => {
const n = new Normalizer(checker);
const nested = n.canon(aliases.get("Nested")!);
expect(nested.k).toBe("union");
if (nested.k !== "union") return;
// The array closes on itself through the union of its element.
const array = nested.arms.find((a) => a.k === "array");
const elem = array?.k === "array" ? array.elem : undefined;
expect(elem?.k === "union" && elem.arms.includes(array!)).toBe(true);
const chain = n.canon(aliases.get("Chain")!);
expect(chain.k).toBe("union");
if (chain.k !== "union") return;
const tuple = chain.arms.find((a) => a.k === "tuple");
const rest = tuple?.k === "tuple" ? tuple.elems[1]!.type : undefined;
expect(rest?.k === "union" && rest.arms.includes(tuple!)).toBe(true);
});
test("optional and rest tuple elements keep their flags", () => {
expect(key(new Normalizer(checker).canon(aliases.get("Loose")!))).toBe(
"[string, number | undefined?, ...boolean]",
);
});
test("classes are nominal; arrays and tuples keep their elements", () => {
const n = new Normalizer(checker);
expect(key(n.canon(aliases.get("Points")!))).toBe("readonly Point[]");