Types in JavaScript
You’re probably aware that types in JavaScript are prototype-based. You may not be aware of exactly how cool that is. Prototypes (also known as exemplars and pioneered in langauges like Self, create new values by copying an existing value. There’s no need for a separate abstraction, like classes, to represent types. You simply designate a value as a prototype (exemplar) and make copies of it.
Unfortunately, JavaScript tends to obscure this simplicity.
The (well-intentioned) introduction of classes in ES6 is one example.
The lack of a standard interface for identifying a value’s prototype is another.
ES6 (quietly) addressed this latter problem with introduction of Object.getPrototypeOf
.
Which means we can reliably write code that knows about prototypes.
A Deeper Understanding Of Prototypes
Let’s take advantage of this to explore how types really work in JavaScript. We don’t really need classes in JavaScript. They’re there if we want to use them. But in a functional world, we have the freedom to discard them in favor of a pure prototype-based model. (For which classes are just sugar-coating anyway, at least the way they’re implemented in ES6.)
The Basics
First, let’s define a simple wrapper around Object.getPrototype
.
(As always, our code is in CoffeeScript but these functions all work fine in JavaScript, and semantically, there’s no difference.)
prototype = (value) -> if value? then Object.getPrototypeOf value
Next, let’s define a function that allows us to check the prototype.
isPrototype = curry (p, value) -> p? && p == prototype value
We can now check the type of a value by comparing it to the prototype
property of a given type.
This works because, by convention, a “type” in JavaScript is, in fact, an object with a prototype
property.
That’s the property we copy when we want to create a new instance of that type.
isArray = isPrototype Array.prototype
assert isArray [1,2,3]
assert !isArray 7
We take advantage of the fact that we can curry isPrototype
to define isArray
a simply a curried version of isPrototype
.
So far, so good. But we can still clean this up a bit.
isType = curry (type, value) -> isPrototype type?.prototype, value
Now we can easily define type-checking helpers. We take advantage of currying again, only now we can just pass in the type, instead of the prototype.
isArray = isType Array
assert isArray [1,2,3]
assert !isArray 7
Prototype Chains
You’re probably also aware of the so-called prototype chain and that JavaScript uses it instead of inheritance. All this means is that we define one prototype by copying another. If we have an prototype for a bank account, we can copy that to create a prototype checking account. After all, in a sense, we’re creating a bank account. It’s just a bank account that we’re going to tweak so that we can use it as a prototype for a checking account.
However, if we want to answer the question whether a given value is a bank account, we can no longer simply rely on the value’s prototype. Because its prototype might be our prototype checking account. Which is not the same as our prototype bank account. What we need to do is check the entire chain of prototypes to see if we eventually reach the bank account prototype.
Property Lookups
JavaScript follows this same algorithm to resolve property references.
It first checks the value directly for the given property.
Then it checks the value’s prototype.
Then it checks the prototype of that prototype.
And so on, until it reaches a value with no prototype (that is, the value of the prototype is null
.)
BankAccount = prototype: number: 0
CheckingAccount = prototype: Object.create BankAccount.prototype
myAccount = Object.create CheckingAccount.prototype
assert myAccount.number == 0
Even though we never defined a number
property on myAccount
or on the CheckingAccount
prototype from which we created it, it’s still there.
Of course, that’s because we defined it on the prototype for BankAccount
, which we used to define the prototype for CheckingAccount
.
Type Lookups
Of course, we want our helper functions to be able to take this prototype chain into account.
What about instanceof
?
The problem with instanceof
is that, for historical reasons, it doesn’t work as reliably as we’d like.
# huh?
assert !(7 instanceof Number)
So we need to define a function to allow us to check the prototype chain directly. This is analogous to our function that checks the prototype.
isTransitivePrototype = curry (p, value) ->
p? && (p == (q = prototype value) || (q && isTransitivePrototype p, q))
We check to see if the given prototype is in fact that prototype for the given value.
If not, we call isTransitivePrototype
recursively with the value’s prototype, effectively following the prototype chain.
Again, taking advantage of the JavaScript convention that a type is an object with a prototype
property, we can define a type-centric variant of the same function.
This is the prototype-chain tracing analog to isType
.
isKind = curry (type, value) -> isTransitivePrototype type?.prototype, value
We can now define type-checking helpers based on the prototype chain.
isBankAccount = isKind BankAccount
assert isBankAccount myAccount
assert !isBankAccount, number: 1
By currying isType
and isKind
we can easily define type-checking functions.
But what about defining new types?
Can we make that a little easier?
Defining Types
One solution, of course, is to use JavaScript classes. But recall that classes are really just syntactic sugar around prototypes. Instead of obscuring the elegance of JavaScript’s protoypes, why don’t we take advantage of it?
Let’s define two helper functions for defining new types and creating instances of those types.
Type =
create: (type) -> if type? then Object.create type.prototype
define: (parent = Object) -> prototype: Type.create parent
Now we can define our bank account and checking account types more succinctly.
BankAccount = Type.define number: 0
CheckingAccount = Type.define BankAccount
myCheckingAccount = Type.create CheckingAccount
assert isType CheckingAccount, myCheckingAccount
assert isKind BankAccount, myCheckingAccount
Initialization
You’ve probably noticed by now that we’re not using constructor functions or the new
operator anywhere.
One reason we’re avoiding this is that we can (accidentally) call constructor functions like ordinary functions.
(That is, without using the new
operator.)
This can lead to unexpected bugs which are difficult to find.
We can prevent these by checking the value of this
in the constructor function, but that’s still error-prone and awkward.
And there
are other reasons
to avoid using new
anyway.
Instead, we construct objects using our Type.create
function
.
But what if we want to initialize the value?
For example, what if we want to assign a unique account number to each account upon creation.
Simple: just add a create
method to your type object.
CheckingAccount.generateAccountNumber = do (n = 0) -> -> n++
CheckingAccount.create = ->
account = Type.create CheckingAccount
account.number = CheckingAccount.generateAccountNumber()
account
firstAccount = CheckingAccount.create()
secondAccount = CheckingAccount.create()
assert firstAccount.number != secondAccount.number
Shared Initialization
This works fine for a single type.
But what if we want to ensure unique account numbers across all BankAccount
values?
If we were using classes, we’d reference the super class constructor, which would take care of setting the account number.
But with prototype chains, there’s no “super” class.
So how can we allow CheckingAccount
to take advantage of common initialization details implemented by all bank accounts?
The most general solution is to explicitly call the desired initialization function.
That is, just call something along the lines of BankAccount.initialization
.
While this isn’t as succinct or expressive as super
, there’s nothing wrong with being explicit.
BankAccount = Type.define()
BankAccount.generateAccountNumber = do (n = 0) -> -> n++
BankAccount.initialize = (account) ->
account.number = BankAccount.generateAccountNumber()
account
CheckingAccount = Type.define BankAccount
CheckingAccount.create = ->
BankAccount.initialize Type.create CheckingAccount
Arguably, this is actually easier to reason about than super
.
Making Copies
We can now easily define types based on prototypes, check the prototype of a value, and check the prototype chain. We can even check the type or ancestor type of a value. All without polluting JavaScript’s simple and elegant prototype-based type system.
And, of course, all these function are available in Fairmont, our functional reactive programming library. But whether you use Fairmont or not, these basic ideas are both fundamental to JavaScript and can help you understand the real underpinnings of JavaScript’s approach to types.