Dev Synthesis Lesson · Web Stack · JS + TS (W1 alt) · 2026-07-15
JavaScriptTypeScriptBranded TypesDiscriminated Union

Type-Enforced Authorization
Boundaries in JS and TS

Branded Token Types, Discriminated-Union Auth State, and the Compile-Time Deny-by-Default Fetch Wrapper

Tier Web Stack · Wednesday JS-anchor + TS co-language (W1 alt)
Date 2026-07-15 · Wednesday · Fajr cron-fired anchor
Refraction Ops per-request PDP → TS compile-time deny-by-default
Tome grounding Programming TypeScript (Cherny) Ch 6 — Branded Types / Discriminated Unions (arc-canonical)
Paired Ops Continuous Authorization for Multi-Agent Fleets (β-Trust)

§IFrame

Today's Ops lesson put a policy decision point in front of every request and made it answer permit-or-deny before an action fires. That check lives on the server, and it runs at runtime, once per request. Good. Now look at the client that makes those requests. A browser holds a short-lived access token in memory (the 07-08 lesson), attaches it to a fetch, and calls the API. Where does the client decide it is allowed to make the call?

In plain JavaScript, it decides at runtime or not at all. A role guard runs an if, and if the developer forgets the if, the call goes out unguarded. The check is a habit, and habits are skippable. TypeScript offers something the server cannot: the ability to make the unauthorized call not compile. The deny-by-default floor stops being a runtime branch and becomes a fact the type checker enforces before the code ever runs. This lesson writes the same authorization boundary twice, once in JS where the guard is a runtime convention and once in TS where the guard is a type, so the contrast between checked-at-runtime and proven-at-compile-time is visible in one place.

§IILanguage Idiom: Two Guards, One Boundary

The JavaScript idiom is the runtime guard. A function inspects a value and throws or returns early if the value is wrong. It is honest and it works, but it protects only the paths the developer remembered to route through it. A raw string token can be passed anywhere a string is accepted, including straight into a fetch that skipped the guard entirely. The type system sees string and says nothing, because every token is a string and so is every unvalidated blob of text.

The TypeScript idiom is the branded type. You take a structurally ordinary value, a string, and give it a nominal tag that no other string carries, so a function can demand this specific brand rather than any string. Boris Cherny covers this as type branding and discriminated unions in Programming TypeScript Ch 6 (pp. 130–148): the brand is a phantom property that exists only in the type world, costs nothing at runtime, and makes a validated token a different type from an unvalidated one. Once fetch demands an AuthorizedToken and only the validator can produce one, the compiler refuses any call that did not pass the validator. The guard is no longer a convention. It is a precondition the type checker will not let you violate.

§IIICode Worked Example

Start with the untyped version. This is the 06-24 pattern: a runtime guard that works when called and does nothing when skipped.

javascript
function authorizedFetch(token, url, action) {
  if (!token) throw new Error("no token");
  if (!token.scopes.includes(action)) throw new Error("denied: " + action);
  return fetch(url, { headers: { Authorization: "Bearer " + token.value } });
}

Nothing stops a caller from writing fetch(url, { headers: { Authorization: "Bearer " + rawToken } }) and bypassing the check completely. The guard is one code path among many, and the runtime never insists you take it.

Now move the floor into the type system. First the brand: a token that has been validated carries a phantom tag, and the only way to obtain one is to run the validator.

typescript
type Action = "capital.read" | "capital.write";

declare const valid: unique symbol;
type AuthorizedToken<A extends Action> = {
  readonly value: string;
  readonly scopes: ReadonlySet<Action>;
  readonly [valid]: A;
};

function authorize<A extends Action>(
  raw: { value: string; scopes: Set<Action> },
  action: A
): AuthorizedToken<A> | null {
  return raw.scopes.has(action)
    ? ({ value: raw.value, scopes: raw.scopes, [valid]: action } as AuthorizedToken<A>)
    : null;
}

The authorize function is the only place the valid symbol is written, so it is the only source of an AuthorizedToken. It returns null on denial, which forces the caller to handle the deny branch before it can reach the token. Now make fetch demand the brand.

typescript
function callApi<A extends Action>(
  token: AuthorizedToken<A>,
  url: string
): Promise<Response> {
  return fetch(url, { headers: { Authorization: `Bearer ${token.value}` } });
}

callApi accepts only an AuthorizedToken. A raw string will not type-check. A null will not type-check. The unauthorized call is now a compile error, and the deny-by-default floor is enforced by the type checker rather than by the developer's memory. The final property comes from the type parameter A: a token authorized for "capital.read" has type AuthorizedToken<"capital.read">, and a function that requires AuthorizedToken<"capital.write"> rejects it. The scope is checked at the type level, so a read token cannot be handed to a write path even by accident.

§IVThe Discriminated Union for Auth State

The browser is not always authorized; it moves through states, and modeling those states as a discriminated union is the second half of the idiom. Cherny's tagged unions (Ch 6) let each state carry exactly the data it should and nothing it should not.

typescript
type AuthState =
  | { status: "anonymous" }
  | { status: "authenticating" }
  | { status: "authorized"; token: AuthorizedToken<Action> }
  | { status: "denied"; reason: string };

function render(state: AuthState): string {
  switch (state.status) {
    case "authorized": return `ready: ${state.token.value.slice(0, 6)}…`;
    case "denied":     return `blocked: ${state.reason}`;
    case "authenticating": return "checking…";
    case "anonymous":  return "sign in";
  }
}

Only the "authorized" variant carries a token, so there is no way to read state.token unless the compiler has already narrowed the state to authorized. Reaching for the token in the "denied" branch is a type error, not an undefined at runtime. The type system holds the same rule the Ops decision point holds: no token surfaces unless the state that produced it was permit. The impossible state, a denied status carrying a live token, cannot be written down.

§VConnection to Today's Ops Lesson

The Ops policy decision point evaluates principal + action + resource + context and returns permit or deny on every request. TypeScript performs the structurally identical check, moved to compile time and to the client. The brand is the client-side proof that the validator ran, the way the Ops enforcement point trusts a token because the identity layer verified it. The AuthorizedToken<A> type parameter is the client's action field, checked statically. The discriminated union is the client's copy of deny-by-default: the only state that exposes a usable token is the permit state, so an unauthorized call is unrepresentable at compile time. The server still re-checks per request; the client's type proof is convenience and early failure, never a replacement for the Ops PDP. The two guards enforce one rule at two seams.

§VIPrior-Lesson Reach

The 07-08 browser-credentials lesson held the access token in memory as a closure variable; this lesson gives that variable a brand so it cannot leak into an unguarded fetch. The 06-24 client-authorization lesson wrote the role guard as a runtime if; this lesson keeps that if inside authorize and then makes the type system enforce that every path runs it. The 07-01 supply-chain lesson introduced typed provenance boundaries in JS and TS; the branded token is the same move applied to authority instead of provenance, a value you can trust because the type proves where it came from.

§VIIClosing

In JavaScript the guard is a habit and the raw token is a string that fits anywhere. In TypeScript the guard is a precondition and the validated token is a type that fits only where authority belongs. Brand the token, parameterize it by the action it permits, and model the auth state as a discriminated union whose only token-bearing variant is the permit state. The unauthorized call stops being a bug you catch in review and becomes a line that will not compile.

Write the boundary once as a runtime if and once as a type, and read the difference aloud. The first asks the developer to remember. The second refuses to build until the developer already has.

Related
[[Archmagus-Stack/Polyglot-Dev/Web/2026-07-08-short-lived-credentials-in-the-browser-javascript-token-refresh-the-in-memory-only-storage-discipline-and-htmls-credential-management-and-secure-context-boundaries]] — prior Web arc (the in-memory token this lesson types)
[[Archmagus-Stack/β-Trust/Synthesis-Lessons/2026-07-15-continuous-authorization-for-multi-agent-fleets-per-request-policy-evaluation-short-lived-token-re-attestation-and-the-deny-by-default-authorization-service]] — paired Ops lesson (the runtime PDP)
[[Archmagus-Stack/Cert-Prep/CNCF/2026-07-15-kubernetes-workload-identity-and-bound-service-account-tokens-cka-serviceaccounts-and-rbac-binding-meets-cks-bound-projected-tokens-audience-scoping-and-the-tokenrequest-api]] — paired Cert lesson
Programming TypeScript (Cherny) Ch 6 Advanced Types — grounding tome
🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-07-15 · Wednesday · Web (JS + TS, W1 alt) · Fajr cron-fired Dev Synthesis Lesson
Grounded in: Programming TypeScript (Cherny) Ch 6 (arc-canonical; Layer-2 lattice unavailable, knowledge-gap logged)