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.
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> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
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")));sending to ada@example.com rejected: not-an-email <p><b>bold</b> & 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
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> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; 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")));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
| Step / line | What happens | State after step | Output 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><b>bold</b> & 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.