TypeScript: branded types for validated and escaped data

Use the type system so that unvalidated text cannot be passed where a validated value is required.

TypeScript
type Brand<T, B extends string> = T & { readonly __brand: B };
type Email = Brand<string, "Email">;
type SafeHtml = Brand<string, "SafeHtml">;

function parseEmail(text: string): Email | null {
  return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(text) ? (text as Email) : null;
}

function escapeHtml(text: string): SafeHtml {
  const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
  return text.replace(/[&<>"']/g, (ch) => map[ch]) as SafeHtml;
}

function sendTo(address: Email): string {
  return "sending to " + address;
}

function paragraph(html: SafeHtml): string {
  return "<p>" + html + "</p>";
}

for (const input of ["ada@example.com", "not-an-email"]) {
  const email = parseEmail(input);
  console.log(email === null ? "rejected: " + input : sendTo(email));
}
console.log(paragraph(escapeHtml("<b>bold</b> & more")));
Output
sending to ada@example.com
rejected: not-an-email
<p>&lt;b&gt;bold&lt;/b&gt; &amp; more</p>

Explanation

Many vulnerabilities come from using raw input where checked input was expected: an unescaped string put into HTML, or an unvalidated string used as an email address. A branded type tags a base type, here string, with a phantom property so that the compiler treats Email and SafeHtml as different from plain string.

Only the validating function is allowed to create the branded value, using a type assertion in a single place. Code that needs a SafeHtml must receive one from escapeHtml, and passing a raw string is a compile error. This catches mistakes early, but brands exist only at compile time. The validation and escaping must still be correct at run time.

Explore the dry run
Guided walkthroughReady
1type Brand<T, B extends string> = T & { readonly __brand: B };
2type Email = Brand<string, "Email">;
3type SafeHtml = Brand<string, "SafeHtml">;
4
5function parseEmail(text: string): Email | null {
6 return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(text) ? (text as Email) : null;
7}
8
9function escapeHtml(text: string): SafeHtml {
10 const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
11 return text.replace(/[&<>"']/g, (ch) => map[ch]) as SafeHtml;
12}
13
14function sendTo(address: Email): string {
15 return "sending to " + address;
16}
17
18function paragraph(html: SafeHtml): string {
19 return "<p>" + html + "</p>";
20}
21
22for (const input of ["ada@example.com", "not-an-email"]) {
23 const email = parseEmail(input);
24 console.log(email === null ? "rejected: " + input : sendTo(email));
25}
26console.log(paragraph(escapeHtml("<b>bold</b> & more")));

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
Email is a string that has passed validation, a different type from plain string.—
—
The only place where the brand is applied, after the check succeeds.—
—
This function accepts only validated addresses. sendTo("x") would not compile.—
—
Narrowing proves email is not null here.—
sending to ada@example.com
rejected: not-an-email
paragraph accepts only SafeHtml, so the text must pass through escapeHtml first.—
sending to ada@example.com
rejected: not-an-email
<p>&lt;b&gt;bold&lt;/b&gt; &amp; more</p>

Cost and common mistakes

O(m) for an input of m characters.

  • Type assertions are an escape hatch. Keep them in a few small, well-tested functions, and never cast raw input yourself.
  • The brand does not exist at run time. Data parsed from JSON still needs real validation.
  • A regular expression for email addresses is only a rough check. The real test is sending a confirmation message.

Try it yourself

Why not just use string for everything?

Reveal answer and reasoning

Then the compiler cannot tell checked from unchecked text, so it is easy to pass raw input to a function that assumed safety.

Further reading