Skip to content

Branded primitive types do not compile: object-literal brands report SC2006, unique symbol brands report SC2008 #282

Description

@paoloanzn

Branded types — a primitive intersected with a marker object — do not compile. This is the usual TypeScript pattern for keeping apart values that share an underlying type, such as a UserId that should not be accepted where a plain string is expected. The marker member exists only in the type system. At runtime the value is the primitive itself.

Version: scriptc 0.0.35, macOS 26.5.1 arm64, Homebrew clang 22.1.4. Also present on main at c41f1a8.

Reproduce

A brand written as an object literal is reported as an index signature problem:

type UserId = string & { readonly __brand: "UserId" };

function toUserId(s: string): UserId {
  return s as UserId;
}

const id = toUserId("paolo");
console.log(id);
$ scriptc build brand.ts -o brand
brand.ts:3:10 - error SC2006: values of type 'UserId' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown')
brand.ts:7:7 - error SC2006: values of type 'UserId' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown')
brand.ts:7:12 - error SC2004: uses of 'toUserId' inherit the blocker on its declaration

A brand written with a unique symbol key is reported as an intersection problem:

declare const tag: unique symbol;
type Meters = number & { readonly [tag]: "Meters" };

const d = 5 as Meters;
console.log(d * 2);
$ scriptc build meters.ts -o meters
meters.ts:4:7 - error SC2008: values of type 'Meters' cannot be compiled: this intersection resolves to no runtime shape
meters.ts:5:13 - error SC2004: uses of 'd' inherit the blocker on its declaration

Under Node both programs run and print paolo and 10.

Why it is worth fixing

The two diagnostics come from different parts of the compiler, but the cause is the same. An intersection whose only runtime-carrying part is a primitive has no mapping, so one form falls through to the record path and the other reaches the unresolved-intersection fence.

tests/diagnostics/intersection-values.ts currently treats this shape as unbuildable, describing it as "a primitive part against an object part (inhabited only per the checker, never buildable)". That holds when the object part is a class instance, because no value can be both a number and a class instance. It does not hold for a brand. The marker member has no runtime existence, so a value of UserId is an ordinary string and a value of Meters is an ordinary number. Both can be built, stored, passed and computed with exactly like their unbranded counterparts.

There is also no partial workaround. The brand has to be removed from the type, which gives up the safety it was added for.

Context

Found while compiling a small program that used a branded string for a user name.

Written with AI assistance and reviewed by a human.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions