Contents
The Problem
type Username = string;
type Street = string;
let myName: Username = "shawr";
let myStreet: Street = "90th";
// This shouldn't be allowed
myName = myStreet;
Realistically, usernames & streets shouldn't overlap.
There are a few different use cases for this.
They generally are when you have a value that you wish to have a specific meaning or format, but is otherwise indistinguishable from the primative type.
Or, put another way:
When you want to express a precondition as a type.
Defining a tagged type
export type Tag<T extends string> = { _tag: T };
export type Tagged<BaseType, T extends string> = BaseType & Tag<T>;
Example: Percentages
Handling percentages
Sometimes we end up juggling two different representations of a scalar value:
- factors: 0 - 1
- percentages: 0 - 100
Yet both of these are numbers and are otherwise indistinguishable. With branded types, we can callout a percent as a distinct type; so anywhere we want to use a Percent
, we can assert it as such.
type Percent = Tagged<number, "Percent">; // 0-100%
function calcTestPoints(points: number, pct: Percent): number {
return (points * pct) / 100;
}
John got a 85% on a test worth 43 points.
// Bad: Argument of type 'number' is not
// assignable to parameter of type 'Percent'.
const johnGrade1 = calcTestPoints(43, 85);
// Good:
const johnGrade2 = calcTestPoints(43, 85 as Percent);
Example: Usernames
Normalizing usernames
Similar idea as percentages: we have usernames, which are represented by strings.
Because usernames are often used as keys, we likely want to normalize the username so that we don't have duplicate keys for the same user.
Define a username
type Username = Tagged<string, "Username">; // shawr
function normalizeUsername(username: string): Username {
return username.trim().toLowerCase() as Username;
}
Utility that accepts a username.
// Gets if username exists; CASE & SPACE SENSITIVE
async function checkIfUserExists(username: Username) {}
Sensitive to the input we provide.
We want to use the utility
async function createUser(username: Username) {
// We can assume the username is
// already normalized because of the type.
if (await checkIfUsernameExists(username)) {
return;
}
// Continue to create user.
}
We only want to normalize once.
We normalize at the boundary layer
// POST /user - Create a new user if it doesn't already exist.
app.post("/user", async (req, res) => {
// ^^^^ Unsafe, unvalidated
const user = normalizeUsername(req.username);
// vvvv Safe, validated
await createUser(user);
});
Because we're not actually populating the _tag
field (only making TS think we are), usage of a branded type incurs no run-time penalty.