Suggestion: `brand type` for finite literal brands with companion `.is` / `.from`

Open
#64,364 1 comment 0 reactions 0 assignees View on GitHub

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:

  1. Nominal separation of two brands with the same members
  2. Block widened string / number
  3. Accept trusted member literals at call sites (setRole("admin"))
  4. 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 AccountType with 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:

Happy to adjust design to feedback; treating the fork as a feasibility check, not a finished PR.

Open questions
  1. Keyword: brand type vs something aligned with #202 (unique / opaque)?
  2. Phase 1: string literal unions only, or also number/bigint?
  3. Failed .from: always TypeError?
  4. 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from microsoft/TypeScript

All issues in microsoft/TypeScript

Similar issues

More Go issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.