In TypeScript, a branded type such as `type Email = string & { readonly __brand: 'Email' }` can only be produced with an `as` assertion. How do you structure code so that assertion is trustworthy, and what does the brand still not guarantee?
answer
- one door into the type
- validate first, assert once
- failure has to be representable
- any is assignable to everything
- provenance, not evidence
basics
~20 sConfine the assertion to one exported factory that checks the value first and returns the branded type or a failure, and export the type without any other way to mint it. The brand still guarantees nothing at runtime: it records that a value passed through that factory, nothing about the value itself.
solid answer
~50 sThe pattern is a smart constructor: one module owns the branded type, exports a single function like `toEmail(raw: string): Email | undefined` that validates and only then writes `raw as Email`, and exports no other route to a branded value. Every `Email` in the program therefore came through code you can read and test, and downstream functions taking `Email` can stop re-checking. Enforce it — a lint rule banning assertions to branded types outside their owning module, because the compiler will never object. What it does not give you is any runtime property: `as` performs no check, the brand is erased, and anything typed `any` — a `JSON.parse` result, an untyped library return, a cast at a network boundary — flows into `Email` silently. Values crossing a process boundary must be re-validated on arrival; a brand is a claim about provenance inside one program, not evidence about the string.
code
typescript · 16 linestype Email = string & { readonly __brand: 'Email' };
// the only place an Email can be created
export function toEmail(raw: string): Email | undefined {
const trimmed = raw.trim();
return trimmed.includes('@') ? (trimmed as Email) : undefined;
}
export function send(to: Email, body: string): void {
console.log(`sending to ${to}: ${body}`);
}
const parsed = toEmail(' [email protected] ');
if (parsed !== undefined) {
send(parsed, 'hello');
}go deeper
Know that a branded value can only be created with an assertion, and that the assertion belongs inside a function that checks the value first rather than scattered at call sites.
Explain what the factory buys downstream — functions taking the branded type stop re-validating — and why the constructor must be able to report failure rather than always returning the branded type.
Demonstrate where the guarantee ends: assertions elsewhere, any-typed parse results, and process boundaries all defeat it, so name the lint rule and the unknown-at-the-edge habit that hold the line.
Own the policy: which invariants are worth encoding as brands at all, where the validation boundary sits in the architecture, and how the rule is enforced across teams so the pattern does not decay into decorative types.
## Why a factory is needed at all A branded type is deliberately uninhabitable by ordinary means: no plain `string` is assignable to `string & { readonly __brand: 'Email' }`. The only way to produce one is an assertion. That is not a flaw — it is the mechanism. It concentrates every act of "I declare this string to be an Email" into syntax you can search for, lint against, and confine. ## The smart constructor ```ts type Email = string & { readonly __brand: 'Email' }; export function toEmail(raw: string): Email | undefined { const trimmed = raw.trim(); return trimmed.includes('@') ? (trimmed as Email) : undefined; } export function send(to: Email, body: string): void { // no re-validation here: the type already says it happened } ``` Three properties make this work: 1. **One assertion site.** The `as Email` appears exactly once, immediately after the check, in the module that owns the type. 2. **Failure is representable.** Returning `Email | undefined` (or a result object, or throwing) forces the caller to deal with invalid input at the boundary rather than deeper in the call graph. A constructor that cannot fail is a constructor that is not validating. 3. **No second door.** Do not also export a `unsafeAsEmail` helper unless you genuinely need one for tests, and if you do, name it so a reviewer notices. ## What the type now buys you The payoff is that validation moves from a runtime obligation repeated everywhere to a compile-time fact carried by the type. A function that takes `Email` does not defensively re-check the format; a function that takes `string` cannot pretend it received a checked one. Push the constructor to the edge of the system — request parsing, config loading, database reads — and the interior of the program only ever handles validated values. This is the "parse, don't validate" discipline expressed in the type layer. It also makes the invariant greppable. "Where can an Email come into existence?" has one answer, and code review of that one function is code review of the invariant. ## What the brand does not guarantee This is the half that separates a good answer from a recited pattern. **The compiler never checks the brand.** `as` is an assertion; it emits nothing and verifies nothing. If someone writes `raw as Email` in another file, the checker accepts it. The invariant is preserved by convention and tooling — typically an ESLint rule forbidding assertions to branded types outside their module — not by the type system. **`any` dissolves it.** `JSON.parse` returns `any`, and `any` is assignable to everything, so `const e: Email = JSON.parse(body).email` compiles with no complaint and no validation. The same applies to untyped third-party returns and to `as any as Email` chains. A branded codebase is only as strong as its `any` discipline; `unknown` at parse boundaries is the companion habit. **It does not survive a process boundary.** Because the brand is erased, a validated `Email` serialized to JSON and read by another service arrives as an ordinary string. The receiving side must run its own constructor. A brand is a statement about provenance *within one compiled program*. **It says nothing about staleness or authorization.** A `UserId` brand tells you a string passed the id-shaped check, not that the user exists or that the caller may see them. Do not let a brand's reassuring name absorb invariants it never encoded. ## Operational shape In practice the boundary layer looks like: accept `unknown`, run a schema check, hand the checked fields to the branded constructors, and let everything inward speak in branded types. Errors are produced once, at the edge, with the context needed to report them. Tests target the constructor directly, because it is the only place the invariant is decided. ## A common design question Should the constructor throw or return a failure value? Throwing suits application code where an invalid value at the edge is a bug or a 400 response handled centrally; returning `Email | undefined` or a result type suits libraries and code paths that must accumulate multiple validation failures. Either is defensible; what is not defensible is a constructor that returns `Email` unconditionally, since that silently reintroduces the unchecked assertion it was meant to contain.
- A JSON body is parsed and one field is assigned to a variable typed Email. What does the compiler say?Nothing — `JSON.parse` is typed to return `any`, and `any` is assignable to every type, so an entirely unvalidated string becomes an `Email` silently. This is the most common way a branded codebase leaks. The fix is to type parse boundaries as `unknown`, run a real schema check, and pass the checked strings through the branded constructors; branding and `any` discipline only work as a pair.
- Should the constructor throw on invalid input or return a failure value?Both are defensible and the choice follows the caller. Throwing suits application edges where invalid input is already handled centrally as a 400 or a startup failure; returning `Email | undefined` or a result object suits libraries and any path that must collect several failures before reporting. What is never right is returning `Email` unconditionally — that reintroduces the unchecked assertion the constructor exists to contain.
- How do you stop a colleague writing `raw as Email` somewhere else in the codebase?Not with the type system — it will accept it. Use tooling and review: a lint rule banning type assertions to branded types outside their owning module, keeping the branded aliases in a small module that shows up in review, and naming any deliberate escape hatch something conspicuous like `unsafeAsEmail` so it cannot be used by accident.
saying these in an interview costs you the question
- Thinks `as` runs a check when the target is branded
- Assumes a branded parameter cannot receive unvalidated data
- Assigns a JSON.parse result to a branded type without validating
- Exports the branded type with no constructor and lets callers cast
- Expects the brand to survive serialization to another service