Suggestion: `brand type` for finite literal brands with companion `.is` / `.from`
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 30/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- go, typescript
- Domain
- compilers
Research direction
Start with the proposed Mode A syntax and the prototype branch feature/brand-type-mode-a, then inspect the brandTypeModeA_* tests under tsc/testdata/tests/cases/compiler/. Compare the prototype with the open questions about syntax, literal kinds, errors, and companion mergeability; the issue is not ready for implementation until the design and expected behavior are agreed.
Written by the indexing model from the issue text.
Description
AI disclosure
This suggestion and a working prototype were drafted with assistance from Cursor (AI coding tools). I have read the design and the prototype, understand the intended behavior, and will shepherd discussion and any follow-up myself.
Problem
Today there is no first-class way to get all four of:
- Nominal separation of two brands with the same members
- Block widened
string/number - Accept trusted member literals at call sites (
setRole("admin")) - Generated runtime guard / constructor (
.is/.from)
| Approach | Literals OK | Blocks string |
Nominal | Generated guard |
|---|---|---|---|---|
"admin" | "regular" |
Yes | Yes | No | No |
String enum |
No (#17690) | Yes | Yes | Partial |
Manual T & { __brand } |
No | Yes | Yes | No |
Proposed brand type (Mode A) |
Yes | Yes | Yes | Yes |
Related prior art / discussion: #202, #4895, #17690, PR #33038.
This proposal does not try to solve general nominal / opaque typing (#202). It targets one concrete gap: finite opaque sets with ergonomic literals.
Suggested solution (normative = Mode A)
brand type AccountType = "admin" | "regular";
Declares:
- Type
AccountType— nominal over the literal union - Value
AccountTypewith generated:
AccountType.is(value: string): value is AccountType
AccountType.from(value: string): AccountType // throws TypeError if !is
Mode A constraints (phase 1):
- RHS is a finite union of string / number / bigint literals
- No open
string/number, intersections, or conditionals in Mode A - Distinct brand names are not mutually assignable even with identical members
Examples
brand type AccountType = "admin" | "regular";
brand type OtherRole = "admin" | "regular";
declare function setRole(role: AccountType): void;
declare const raw: string;
setRole("admin"); // OK — member literal
setRole("regular"); // OK
setRole("superuser"); // Error — not in the set
setRole(raw); // Error — widened string
setRole(AccountType.from(raw)); // OK
if (AccountType.is(raw)) {
setRole(raw); // OK — CFA
}
declare let a: AccountType;
declare let b: OtherRole;
a = b; // Error — different brands
Generated JavaScript (declaration only)
Expressions are not rewritten based on types. "admin" stays "admin" at call sites.
brand type AccountType = "admin" | "regular";
setRole("admin");
const AccountType = (() => {
const values = new Set(["admin", "regular"]);
return {
is(s) { return values.has(s); },
from(s) {
if (!values.has(s))
throw new TypeError("Invalid AccountType: " + String(s));
return s;
}
};
})();
setRole("admin");
Why not “another enum”?
String enum |
Mode A brand type |
|---|---|
| Opaque by design (literals rejected) | Member literals accepted on purpose |
| Reverse maps / widening quirks | No reverse map |
| Namespace-like usage | Companion is only .is / .from |
Philosophy note (vs PR #33038): Mode A’s invariant is decidable membership in a finite literal set — the checker already understands that for unions. Attaching a nominal tag when membership is known does not invent new proofs. Open brands (brand type Email = string) must not auto-accept arbitrary literals; that is explicitly out of Mode A.
Phase 2 (optional, separable) — refined brands
Same dual, custom is, generated from, conservative assignability (no bare number → brand):
brand type PositiveInt = number {
is(n: number): n is PositiveInt {
return Number.isInteger(n) && n > 0;
}
}
Mode B can be rejected without killing Mode A. Libraries already approximate Mode B; Mode A is the part libraries cannot do without casts or a transformer (setRole("admin") on a nominal brand).
Working prototype (POC, not a merge request)
Exploratory implementation on a fork of the native (Go) compiler:
- Branch:
feature/brand-type-mode-a - Mode A commit: https://github.com/mishelashala/TypeScript/commit/edd5a7c8
- Mode B commit (phase 2 sketch): https://github.com/mishelashala/TypeScript/commit/b57c62bf
- Tests:
brandTypeModeA_*,brandTypeModeB_*undertsc/testdata/tests/cases/compiler/
Happy to adjust design to feedback; treating the fork as a feasibility check, not a finished PR.
Open questions
- Keyword:
brand typevs something aligned with #202 (unique/opaque)? - Phase 1: string literal unions only, or also number/bigint?
- Failed
.from: alwaysTypeError? - Companion mergeability: prefer non-mergeable
const-like
Search terms
brand type, finite literal brand, nominal string union, literal assignability, companion .is .from, not enum, not full #202
- Dominant language
- Go
- Stars
- 111k
- Forks
- 14.4k
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 117
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/TypeScript
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
microsoft/TypeScript#64322 · 2 comments · 1 reaction · 2 assignees ·
-
Possible Improvement
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
microsoft/TypeScript#64278 · 1 comment · 1 reaction ·
-
Docs
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
microsoft/TypeScript#64118 · 1 comment ·
-
Difficulty 1/5 Under an hour Newbie friendliness 88/100
microsoft/TypeScript#64094 ·
-
Docs
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
microsoft/TypeScript#63959 · 5 comments ·
All issues in microsoft/TypeScript
Similar issues
-
optimization optimization:agents-md-curator
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
githubnext/gh-aw-cao#13143 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
blinklabs-io/bursa#904 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
yanet-platform/ipfw-go#129 ·
-
bug confmap/provider/googlesecretmanagerprovider needs triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
open-telemetry/opentelemetry-collector-contrib#51273 · 2 comments ·
-
bug: AI Gateway client filter lists "Unknown" twice when NULL and literal Unknown clients coexist Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 90/100