Skip to content

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

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 Support some non-structural (nominal) type matching #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

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