From a1cd6954af85540068812dfd199427fe49d48884 Mon Sep 17 00:00:00 2001 From: Paolo Anzani Date: Wed, 2 Sep 2026 08:24:18 +0200 Subject: [PATCH] Map branded primitives to their carrier type A branded type is a primitive joined to a marker object, for example `string & { readonly __brand: "UserId" }`. The marker exists only in the type system; at runtime the value is an ordinary string. The compiler had no rule for this shape, so it failed in two different ways: brands written as an object literal were treated as records and reported SC2006, and brands written with a `unique symbol` key reached the unresolved- intersection fence and reported SC2008. Map these types to the primitive they wrap. When an intersection has exactly one primitive part, and every other part is a plain object type that adds nothing at runtime, the whole type now maps to that primitive. A branded value is stored, passed and computed exactly like an unbranded one. Reading a brand member is still refused at the point of use, like any other type-only member. Two shapes stay refused because they describe no runtime value: a primitive joined to a class instance, since nothing can be both, and an intersection of two different primitives. The test for a type-only object part already existed inline in the handle-intersection path and is now a shared helper, so both paths use one definition. The primitive lookup is shared with the scalar mapping for the same reason. A new differential program covers string and number brands, `unique symbol` tags, string methods and arithmetic on branded values, branded fields in interfaces, arrays, Maps and Sets, and generic passthrough. The intersection diagnostic fixture now uses a primitive joined to a class, which is the case that still cannot compile. Closes #282 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011KE8gQ92pFxakTj5obV1oB --- packages/compiler/src/frontend/type-mapper.ts | 70 ++++++++++++++--- tests/corpus/2694-branded-primitives.ts | 76 +++++++++++++++++++ tests/diagnostics/intersection-values.ts | 15 ++-- .../__snapshots__/intersection-values.ts.txt | 32 ++++---- 4 files changed, 162 insertions(+), 31 deletions(-) create mode 100644 tests/corpus/2694-branded-primitives.ts diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 0ade93ae0..e853b1df4 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -862,6 +862,50 @@ function classExprNeverRegisters(decl: ts.ClassLikeDeclaration): boolean { return false; } +/** The IR type of a PRIMITIVE ts type — number, string, boolean — or null + * for everything else. The one table: mapTypeInner's scalar dispatch and + * the branded-intersection rule must agree on what a primitive lowers to. */ +function mapPrimitive(flags: ts.TypeFlags): IrType | null { + if (flags & ts.TypeFlags.Number) return F64; + if (flags & ts.TypeFlags.String) return STRING; + if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return BOOL; + return null; +} + +/** True for an intersection constituent that only DECORATES the type: a + * plain object type carrying no runtime shape of its own — no call or + * construct signatures, no class identity. Its members are type-level, so + * uses of them fence per SITE rather than at the type. */ +function isPlainRefinement(part: ts.Type, checker: ts.TypeChecker): boolean { + const sym = part.getSymbol(); + return ( + (part.flags & ts.TypeFlags.Object) !== 0 && + checker.getCallSignatures(part).length === 0 && + checker.getConstructSignatures(part).length === 0 && + (sym === undefined || (sym.flags & ts.SymbolFlags.Class) === 0) + ); +} + +/** The primitive an intersection BRANDS, or null when it brands none. The + * shape is one primitive constituent — the runtime representation — plus + * plain object refinements (isPlainRefinement): the same carrier-plus- + * decoration rule the builtin-handle intersections below follow. Repeating + * one primitive is harmless (`string & string & B`); two different ones + * make the intersection uninhabited, so it stays unmapped. */ +function mapBrandedPrimitive(widened: ts.Type, checker: ts.TypeChecker): IrType | null { + let carrier: IrType | null = null; + for (const part of ts.constituentTypes(widened)) { + const primitive = mapPrimitive(checker.getBaseTypeOfLiteralType(part).flags); + if (primitive !== null) { + if (carrier !== null && carrier.kind !== primitive.kind) return null; + carrier = primitive; + continue; + } + if (!isPlainRefinement(part, checker)) return null; + } + return carrier; +} + function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const { checker, unions, classNamer, resolveTypeParam } = ctx; if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) { @@ -906,9 +950,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const widened = checker.getBaseTypeOfLiteralType(type); const flags = widened.flags; - if (flags & ts.TypeFlags.Number) return F64; - if (flags & ts.TypeFlags.String) return STRING; - if (flags & ts.TypeFlags.Boolean || flags & ts.TypeFlags.BooleanLiteral) return BOOL; + { + const primitive = mapPrimitive(flags); + if (primitive !== null) return primitive; + } // node:util.parseArgs config/results are declaration-heavy conditional // and discriminated-union types over a value that is naturally a checked- // dynamic tree. Keep the named public surface (plus @types/node's private @@ -1198,6 +1243,17 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { fields.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); return { kind: "record", shapeId: ctx.shapes.intern(fields, true) }; } + // BRANDED primitives — the nominal-typing idiom `type UserId = string & + // { readonly __brand: "UserId" }` (a `unique symbol` brand key spells the + // same shape). The brand member lives only in the type world: every value + // of the type IS the primitive, so the mapping is the primitive's own and + // a branded slot holds, passes, and computes exactly like its unbranded + // twin. Reads of a brand member fence per site, like any other refinement + // member. + if (widened.isIntersectionType()) { + const branded = mapBrandedPrimitive(widened, checker); + if (branded !== null) return branded; + } // Class instances: the type's symbol is a class declared in the user's // file. The class NAME as a value has the *constructor* type — same // REFINED handle intersections — @types/node's idioms: `ServerResponse< @@ -1229,13 +1285,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { handle = mapped; continue; } - const partSym = part.getSymbol(); - if ( - (part.flags & ts.TypeFlags.Object) === 0 || - checker.getCallSignatures(part).length > 0 || - checker.getConstructSignatures(part).length > 0 || - (partSym !== undefined && (partSym.flags & ts.SymbolFlags.Class) !== 0) - ) { + if (!isPlainRefinement(part, checker)) { refined = false; break; } diff --git a/tests/corpus/2694-branded-primitives.ts b/tests/corpus/2694-branded-primitives.ts new file mode 100644 index 000000000..01ee530c0 --- /dev/null +++ b/tests/corpus/2694-branded-primitives.ts @@ -0,0 +1,76 @@ +// BRANDED primitives — the nominal-typing idiom. A brand member lives only +// in the type world, so the value IS the primitive: branded slots hold, +// pass, and compute exactly like their unbranded twins, whether the brand +// key is an ordinary property or a `unique symbol`. + +type UserId = string & { readonly __brand: "UserId" }; +type Meters = number & { readonly __brand: "Meters" }; + +declare const tag: unique symbol; +type Brand = T & { readonly [tag]: B }; +type Slug = Brand; +type Ratio = Brand; +type Verified = Brand; + +function userId(raw: string): UserId { + return raw as UserId; +} +function meters(raw: number): Meters { + return raw as Meters; +} + +// The primitive's own surface stays available on a branded value. +const id = userId("ada"); +console.log(id, id.length, id.toUpperCase(), `<${id}>`); +console.log(id === userId("ada"), id < userId("bob")); + +const width = meters(3.5); +console.log(width + 1.5, width * 2, width.toFixed(2), Math.max(width, meters(1))); + +const slug = "hello-world" as Slug; +console.log(slug.split("-").join(" "), slug.startsWith("hello")); + +const ratio = 0.25 as Ratio; +const verified = true as Verified; +console.log(ratio * 4, verified ? "yes" : "no", !verified); + +// The brand is type-level only: the runtime value is the bare primitive. +console.log(typeof id, typeof width, typeof verified, String(width)); + +// Branded values flow through every container slot. +const ids: UserId[] = [userId("ada"), userId("bob"), userId("cy")]; +console.log(ids.join(","), ids.map((u) => u.length).join("|")); + +const seen = new Set(ids); +console.log(seen.size, seen.has(userId("bob"))); + +const scores = new Map(); +scores.set(userId("ada"), meters(12)); +console.log(scores.get(userId("ada")) ?? meters(-1)); + +interface Row { + readonly id: UserId; + readonly depth: Meters; +} +const row: Row = { id: userId("cy"), depth: meters(7.25) }; +console.log(row.id, row.depth, JSON.stringify(row)); + +// Unions, narrowing, and generics over a branded arm. +function label(value: UserId | null): string { + return value === null ? "none" : value.padStart(5, "."); +} +console.log(label(userId("ada")), label(null)); + +function firstOf(items: T[]): T { + return items[0]!; +} +console.log(firstOf(ids), firstOf([width, meters(9)])); + +// A branded parameter accepts the primitive's operations unchanged, and a +// branded return type is just the primitive coming back. +function totalDepth(rows: Row[]): Meters { + let sum = 0; + for (const r of rows) sum += r.depth; + return sum as Meters; +} +console.log(totalDepth([row, { id: userId("bob"), depth: meters(2.75) }])); diff --git a/tests/diagnostics/intersection-values.ts b/tests/diagnostics/intersection-values.ts index 626de444c..a5e98814c 100644 --- a/tests/diagnostics/intersection-values.ts +++ b/tests/diagnostics/intersection-values.ts @@ -1,13 +1,18 @@ // SC2008: intersection types that resolve to no runtime shape. Object-member -// intersections intern through the record path and callable hybrids map to -// '%call' records — what fences is the remainder, like a primitive part -// against an object part (inhabited only per the checker, never buildable). +// intersections intern through the record path, callable hybrids map to +// '%call' records, and a primitive against plain object refinements is the +// BRAND idiom — the value IS the primitive (tests/corpus/2694). What fences +// is the remainder, like a primitive against a CLASS instance: no value is +// both (inhabited only per the checker, never buildable). // The producer has a BODY (an ambient `declare function` would compile to // Node's ReferenceError at the call instead — the declare-erasure stance). -type Branded = number & { __brand: "id" }; +class Tag { + readonly kind = "tag"; +} +type Branded = number & Tag; function mint(): Branded { - return 1 as Branded; + return 1 as unknown as Branded; } const kept = mint(); console.log(kept); diff --git a/tests/harness/__snapshots__/intersection-values.ts.txt b/tests/harness/__snapshots__/intersection-values.ts.txt index f092ad775..23b536910 100644 --- a/tests/harness/__snapshots__/intersection-values.ts.txt +++ b/tests/harness/__snapshots__/intersection-values.ts.txt @@ -1,35 +1,35 @@ -intersection-values.ts:9:10 - error SC2008: values of type 'Branded' cannot be compiled: this intersection resolves to no runtime shape +intersection-values.ts:14:10 - error SC2008: values of type 'Branded' cannot be compiled: this intersection resolves to no runtime shape - 8 | type Branded = number & { __brand: "id" }; - 9 | function mint(): Branded { + 13 | type Branded = number & Tag; + 14 | function mint(): Branded { | ^~~~ - 10 | return 1 as Branded; + 15 | return 1 as unknown as Branded; hint: restate the intersection as a single interface or type literal with the combined members; mixin-produced intersections compile where the mixin chain pins one instantiation -intersection-values.ts:12:7 - error SC2008: values of type 'Branded' cannot be compiled: this intersection resolves to no runtime shape +intersection-values.ts:17:7 - error SC2008: values of type 'Branded' cannot be compiled: this intersection resolves to no runtime shape - 11 | } - 12 | const kept = mint(); + 16 | } + 17 | const kept = mint(); | ^~~~ - 13 | console.log(kept); + 18 | console.log(kept); hint: restate the intersection as a single interface or type literal with the combined members; mixin-produced intersections compile where the mixin chain pins one instantiation -intersection-values.ts:12:14 - error SC2004: uses of 'mint' inherit the blocker on its declaration +intersection-values.ts:17:14 - error SC2004: uses of 'mint' inherit the blocker on its declaration - 11 | } - 12 | const kept = mint(); + 16 | } + 17 | const kept = mint(); | ^~~~ - 13 | console.log(kept); + 18 | console.log(kept); hint: the declaration of 'mint' did not compile — fix the diagnostic reported there and these sites clear with it -intersection-values.ts:13:13 - error SC2004: uses of 'kept' inherit the blocker on its declaration +intersection-values.ts:18:13 - error SC2004: uses of 'kept' inherit the blocker on its declaration - 12 | const kept = mint(); - 13 | console.log(kept); + 17 | const kept = mint(); + 18 | console.log(kept); | ^~~~ - 14 | + 19 | hint: the declaration of 'kept' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file