JS: Breaking the Rules — Runtime type guards
I love exploring the nature of TypeScript. Several years ago I ran across a pattern that I’ve never really used before, but immediately made me admire how much you can do with TypeScript.
TypeScript is great at narrowing types. For example, rather than using type: string;
we can always be more specific and give it a value like type: "product" | "ad";
. But unfortunately its types still correspond to the primitive types of JavaScript.
For example, what if I wanted to specify the type of an id as a positive integer? The most specific I can get with TypeScript is as specific as I can get with JavaScript: the number
type. I can’t just use a union since the union is (almost) infinite.
I often found myself wishing for an integer
type in TypeScript as well as in JavaScript. That’s when I discovered this neat trick called branding.
You can extend just about anything in TypeScript, even if it’s not actually possible with runtime code!
type PositiveInteger = number & { __positiveInteger: true; };
function isPositiveInteger(value: number): value is PositiveInteger {
return Number.isInteger(value) && value > 0;
}
const numbers: number[] = [1, 2, 2.5, 3];
function sum(ints: PositiveInteger[]): PositiveInteger {
const total = ints.reduce((sum, int) => sum + int, 0);
if (!isPositiveInteger(total)) {
console.log("Something went pretty wrong here");
}
return total;
}
sum(numbers); // TypeScript error
sum(numbers.filter(isPositiveInteger));
So how does this work exactly?
You can see that we define a new TypeScript type called PositiveInteger
, and it extends number
. The number
type has all the methods available to numbers in JavaScript, like toFixed()
. Here we’re creating an imaginary type, called a brand, that extends the primitive type and adds __positiveInteger: true
.
You might be asking: but we never added __positiveInteger: true
anywhere in our code! And that’s true! There’s a caveat — we’re telling TypeScript that a value is there when it actually isn’t.
numbers.forEach((number) => {
if (isPositiveInteger(number)) {
// TS would say that the type of `number.__positiveInteger` is `true`
console.log(number.__positiveInteger); // undefined
if (number.__positiveInteger) {
// this would never happen
}
}
});
However, we’re never actually using this property at all! Instead, the secret sauce is defining a type guard:
function isPositiveInteger(value: number): value is PositiveInteger {
return Number.isInteger(value) && value > 0;
}
The only way to get a PositiveInteger
type is to pass a number to the type guard function. This informs TypeScript that our number is in particular a positive integer. While this does not magically add a __positiveInteger: true
property to the number, it does give us the ability to enforce that before a primitive value can be passed to a particular function, it must be checked via a runtime function. Pretty neat, huh?
But this got me thinking.
JavaScript has a really cool language feature: instead of creating actual class
es like other OOP languages, its prototypal inheritance system is modifiable at runtime. It’s really pretty wild!
console.log("abc".length); // 3
console.log("abc".toUpperCase()); // "ABC"
console.log("abc".capitalize()); // TypeError
String.prototype.capitalize = function() {
const first = this.substring(0, 1);
const rest = this.subtring(1);
return `${first.toUpperCase()}${rest}`;
};
console.log("abc".capitalize()); // "Abc"
Literally between two console.log()
statements I modified the prototype of String
, thereby creating a new method that was suddenly available to all strings.
A library called Prototype.js made heavy use of this neat property of JavaScript. It added all kinds of methods to built-in constructors like Array, String, Object, Date, and more. While it is no longer encouraged to modify prototypes, we still use this ability today whenever we add shims to our websites to improve browser compatibility.
For example, arrays didn’t always have the methods they do today! When methods like forEach()
and find()
were added to the official ECMAScript specification, many users continued to use old browser versions that hadn’t implemented these functions. This meant that some browsers might already have a built-in implementation, while for others, calling array.forEach()
would throw a array.forEach is not a function
error.
Shims check if the browser already implements a version of the method, and allow us to “patch” the environment of older browsers for a better coding experience:
[1, 2, 3].forEach(console.log); // error!
if (!Array.prototype.forEach) {
Array.prototype.forEach = function(callback) {
for (let i = 0; i < this.length; i++) {
callback(this[i], i, this);
}
};
}
[1, 2, 3].forEach(console.log); // success!
There are legitimately good reasons not to abuse this feature to add custom methods, though.
JavaScript libraries should be interoperable and not interfere with one another. What if two different libraries both wanted to add a unique()
function to Array
, but they had different opinions on the order of the arguments? Mutating the Array prototype affects all arrays in the execution environment. In some cases, updates to the official ECMAScript specification can also cause conflicts — for example, a method contains()
was added to String, but had to be renamed to includes()
because Prototype.js was still in use and had implemented its own method called contains()
.
But a more recent addition to JavaScript is a really cool new primitive type: symbols. Symbols are like references — only equal to themselves:
const object1 = {};
const object2 = {};
object1 === object1 // true
object1 === object2 // false
const string1 = "string";
const string2 = "string";
string1 === string1 // true
string1 === string2 // true
const sym1 = Symbol("sym");
const sym2 = Symbol("sym");
sym1 === sym1 // true
sym1 === sym2 // false
This property alone doesn’t make them particularly special — no more special than objects. But symbols can be used as keys on objects — something that previously was reserved only for strings.
const sym = Symbol();
const object = {
a: "b",
[sym]: "c",
};
Symbols were designed from the beginning to be… secretive. For example, even though object
has two enumerable properties ("a"
and sym
), Object.keys(object)
only returns ["a"]
.
We can still look up the value directly with object[sym]
. And if we are particularly keen on finding all of the own properties of the object, we can use functions like Object.getOwnPropertySymbols(object)
or Reflect.ownKeys(object)
. But the idea here is that symbols are opaque tokens — in general, the value "c"
is nontrivial to retrieve without having direct access to the value of sym
.
Because symbols are unique references, this actually opens up a really interesting new way to pollute built-in prototypes in a non-conflicting way! Going back to our earlier example of type branding, what if we used the symbol trick to actually add a runtime function to our numbers?
const POSITIVE_INTEGER: unique symbol = Symbol("POSITIVE_INTEGER");
type PositiveInteger = number & { [POSITIVE_INTEGER]: true; };
Object.defineProperty(Number.prototype, POSITIVE_INTEGER, {
get: function(this: number) {
return Number.isInteger(this) && this > 0;
},
});
function isPositiveInteger(value: number): value is PositiveInteger {
return value[POSITIVE_INTEGER] === true;
}
sum(numbers); // TypeScript error
sum(numbers.filter(isPositiveInteger));
We now have a magical property on all numbers, but the only way to access its value is to have the POSITIVE_INTEGER
symbol in scope!
You might have noticed that I used Object.defineProperty()
rather than just the normal property assignment. This is the runtime way to define a “getter” method, which automatically runs the method when the property’s value is accessed. You’ll commonly see this done with an alternative syntax when using class
:
// class way
class UserError {
get name() {
return "UserError";
}
}
console.log(new UserError().name); // "UserError"
// prototype way
function UserError() {}
Object.defineProperty(UserError, "name", {
get: function() {
return "UserError";
},
});
The TypeScript compiler doesn’t know that we modified the Number prototype, but we can make it aware of this by using a declaration file. TypeScript uses many declaration files and applies them based on the configured browser environment, so it always knows the exact specification being used and what methods are available!
We can add our own declaration file and augment the Number interface:
interface Number {
[POSITIVE_INTEGER]: boolean;
}
And we’re done!