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
70 changes: 60 additions & 10 deletions packages/compiler/src/frontend/type-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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;
}
Expand Down
76 changes: 76 additions & 0 deletions tests/corpus/2694-branded-primitives.ts
Original file line number Diff line number Diff line change
@@ -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, B extends string> = T & { readonly [tag]: B };
type Slug = Brand<string, "Slug">;
type Ratio = Brand<number, "Ratio">;
type Verified = Brand<boolean, "Verified">;

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<UserId>(ids);
console.log(seen.size, seen.has(userId("bob")));

const scores = new Map<UserId, Meters>();
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<T>(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) }]));
15 changes: 10 additions & 5 deletions tests/diagnostics/intersection-values.ts
Original file line number Diff line number Diff line change
@@ -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);
32 changes: 16 additions & 16 deletions tests/harness/__snapshots__/intersection-values.ts.txt
Original file line number Diff line number Diff line change
@@ -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