loading...
Cover image for Type Classes in JavaScript

Type Classes in JavaScript

moosch profile image Ryan Whitlie Updated on ・8 min read

Write You Some FP Maths

Another article in a series where I'll be looking at some mathematical concepts and functional programming stuff in JavaScript.

FP FTW!

Type Classes

This time we'll be looking at Type Classes 🔍

Type classes according to Wikipedia:

A type class is a type system construct that supports ad hoc polymorphism.

Great! I'm glad we got that out of the way.

In a more easy to understand way, a type class is a little like an interface in Java, a Behaviour in Elixir, and very similar to a typeclass in Haskell.

It's a data type that has a defined set of behaviours. Similar things already exist in JavaScript. For example the Array data type (or constructor) can be considered a type class of sorts. It defines a set of operations that can be called and those operations themselves define the types of data they process.

So lets make our own type classes in JavaScript! In reality, we'll actually be building some data types, similar to String, Number and Array, but we'll add some ad-hoc polymorphism to ours so that one of our methods (the only one that accepts an argument) can accept strings or numbers only.

Goals

We must be able to use our type class to create a new "instances" of the Type:

(Type('blah') instanceof Type) === true

We must be able to use built-in methods on the "instance" data:

const type = Type('foo');
type.foo() = "foo";

And we must be able to use those same methods on the type class by passing any instance of the type class as data:

const type = Type('foo');
Type.foo(type) = "foo";

Seems reasonable, right? There are plenty languages where you can do operations on arrays with something like

newList = Array.map([1,2,3], doubleFunction)
newString = String.upper("FP rocks!")

And in JavaScript it's more likely to look like this

newList = [1,2,3].map(doubleFunction);
"FP rocks!".toUpperCase();

We also want to adhere to ad-hoc polymorphism but only accept strings and numbers which we will "concat" together in an add function. Special thanks to Maciej Sikora for noticing my mistake of omitting any actual polymorphism. Dev.to community power!

Type.add(type, 'boo');
Type.add(type, 555);
Type.add(type, [1,2,3]); // TypeError

Get on with it

The first type class we're going to build is going to be a simple box. But not all boxes are simple! Remember Schrodinger's 😱

We want to allow our box to contain some data. Any data really. We want to be able to "shake" our box and get some kind of response...perhaps a console log. We also want to be able to peek inside our box, maybe another log there. And of course, we want to be able to open it and get it's contents.

Lets define our box as a function taking one argument, the data, and returning an object with our capabilities (methods).

const Box = (x) => ({
  valueOf: () => x,
  shake: () => {
    console.log(`Box(...)`);
    return Box(x);
  },
  peek: () => {
    console.log(`Box(${x})`);
    return Box(x);
  },
  add: (val) => {
    return Box(`${x}${val}`);
  },
  open: () => x,
});

If you're wondering why shake and peek return Box(x) it's because this keeps our data immutable, meaning we always return a new Box every time we call one of our methods. Except open of course where we only want the data within. But in functional programming we never changing the data within the existing instance.

You may also be questioning why we have valueOf as well. Great question! Well valueOf is a standard method on any JavaScript data type, Array, String, Object etc. We are simply overriding this so we can return only the containing data, rather than [Function: Box]. Yes it is essentially the same as our open function but we are exposing an API for our box, and valueOf is one that we can safely call on any data we get. You'll see why it's important soon.

Enough! Lets play

let box = Box('🤫');
box = box.shake(); // => Box(...)
box = box.peek(); // => Box(🤫)
box = box.add(); // => '🤫'
box = box.open(); // => '🤫'
(box === '🤫') // => true

Awesome! We've done so much already. Well done us.
But we can't do this yet...

let box = Box('🤫');
Box.peek(box); // => TypeError: Box.peek is not a function

This is because Box is a function that expects some data and then returns a JS Object with our keys and functions capabilities. What we want is for Box to also have these methods available directly on itself, just like a true type class. JavaScript Object.defineProperty can help us with that! Though we'll use Object.defineProperties because we are defining more than one.

Object.defineProperties(Box, {
  shake: {
    value: (obj) => {
      console.log(`Box(...)`);
      return Box(obj.valueOf());
    },
  },
  peek: {
    value: (obj) => {
      console.log(`Box(${obj.valueOf()})`);
      return Box(obj.valueOf());
    },
  },
  add: {
    value: (obj, val) => Box(`${obj.valueOf()}${val}`);
  },
  open: {
    value: (obj) => obj.valueOf(),
  },
});

Now we can do this and it works...

let box = Box('🤫');
box = Box.shake(box); // => Box(...)
box = Box.peek(box); // => Box(🤫)
box = Box.add(box, '!'); // => Box(🤫!)
box = Box.open(box); // => '🤫!'
(box === '🤫!') // => true

This is pretty good, but we are duplicating our code here. Lets make this a little more dry and readable by moving our function logic out of the functions and into helpers.

const _boxShake = (data) => {
  console.log(`Box(...)`);
  return data;
};
const _boxPeek = (data) => {
  console.log(`Box(${data})`);
  return data;
};
const _boxAdd = (data, val) =>
  `${data}${val}`;
const _boxOpen = (data) => data;

const Box = (x) => ({
  valueOf: () => x,
  shake: () => _boxShake(x),
  peek: () => _boxPeek(x),
  add: (val) => _boxAdd(x, val),
  open: () => _boxOpen(x),
});

Object.defineProperties(Box, {
  shake: {
    value: (obj) => _boxShake(obj.valueOf()),
  },
  peek: {
    value: (obj) => _boxPeek(obj.valueOf()),
  },
  add: {
    value: (obj, val) => _boxAdd(obj.valueOf(), val),
  },
  open: {
    value: (obj) => _boxOpen(obj.valueOf()),
  },
});

That looks better, but it doesn't work sadly. We need to remember to return a new Box in our shake and peek functions!
But rather than returning a new Box, why don't we send them the Box because our defined property functions accept a Box as an argument, so we just need the Box object methods to return Boxes. And while were here lets add our type safety to our add function. Remember, we only want to accept strings and numbers.

const _boxShake = (data) => {
  console.log(`Box(...)`);
  return data;
};
const _boxPeek = (data) => {
  console.log(`Box(${data.valueOf()})`);
  return data;
};
const _boxAdd = (data, val) => {
  if (isNaN(val) && typeof val !== 'string') {
    throw new TypeError(`Expected either a String or Number type but got ${typeof val}`);
  }
  return Box(`${data.valueOf()}${val}`);
};
const _boxOpen = (data) => data.valueOf();

const Box = (x) => ({
  valueOf: () => x,
  shake: () => _boxShake(Box(x)),
  peek: () => _boxPeek(Box(x)),
  add: (val) => _boxAdd(Box(x), val),
  open: () => _boxOpen(Box(x)),
});

Object.defineProperties(Box, {
  shake: {
    value: _boxShake,
  },
  peek: {
    value: _boxPeek,
  },
  add: {
    value: _boxAdd,
  },
  open: {
    value: _boxOpen,
  },
});

It's looking like we are done right? Not quite yet :)
A true type class in JavaScript should actually have a type right? We should be able to call (box instanceof Box) and expect it to be true. If you try that out now you'll get an interesting error that looks something like this:

TypeError: Function has non-object prototype 'undefined' in instanceof check
    at Function.[Symbol.hasInstance] (<anonymous>)
    at Object.<anonymous> (/Users/ryan/Documents/Personal/git/js-type-classes/sandbox.js:49:8)
    at Module._compile (internal/modules/cjs/loader.js:778:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:831:12)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:622:3)

If you follow the stacktrace there's a clue for how we can help make this expression resolve to true. at Function.[Symbol.hasInstance] (<anonymous>) is our key! If we define a function property of Symbol.hasInstance that does some type checking on our Box, this should work. But what are we checking for...? Our Box right? So lets add an identifier to it that we can use. We'll update our Box with a __typeclass key/value.

const Box = (x) => ({
  __typeclass: 'Box',
  valueOf: () => x,
  shake: () => _boxShake(Box(x)),
  peek: () => _boxPeek(Box(x)),
  add: (val) => _boxAdd(Box(x), val),
  open: () => _boxOpen(Box(x)),
});

Now we can add the Symbol.hasInstance property.

Object.defineProperty(Box, Symbol.hasInstance, {
  value: (instance) => instance['__typeclass'] === 'Box',
});

Excellent, our (box instanceof Box) expression will return true.

We're done...right?
Oh so close, but we need to make sure that whatever is passed into our data type methods (Box.peek(box)) will be the data type we expect, which is an instance of Box. So lets create a function guard to add to our helpers.

const TypeGuard = (ExpectedType, data) => {
  if (!ExpectedType) {
    throw new ReferenceError('Expected a Type as first argument');
  }
  if (
    data === undefined
    || data === null
    || !(data instanceof ExpectedType)
  ) {
    let datatype;
    switch (true) {
      case (data === undefined):
        datatype = undefined;
        break;
      case (data === null):
        datatype = null;
        break;
      default:
        datatype = data.constructor ? data.constructor.name : data;
        break;
    }
    throw new TypeError(`${datatype} is not of type ${ExpectedType.name}`);
  }
};

This may look a little complex but it's simply checking we input our expected type class and then checks the data is of the same instanceof as our expected type. If not, it throws a TypeError. That that switch statement may look funky but it's a nice way to do easily readable conditionals in JavaScript without true pattern matching. It simply checks if a case is true to run some logic, and if it's false it moves to the next case.

Lets add this guard to our helpers.

const _boxShake = (data) => {
  TypeGuard(Box, data);
  console.log(`Box(...)`);
  return data;
};
const _boxPeek = (data) => {
  TypeGuard(Box, data);
  console.log(`Box(${data.valueOf()})`);
  return data;
};
const _boxAdd = (data, val) => {
  TypeGuard(Box, data);
  if (isNaN(val) && typeof val !== 'string') {
    throw new TypeError(`Expected either a string or number type but got ${typeof val}`);
  }
  return Box(`${data.valueOf()}${val}`);
};
const _boxOpen = (data) => {
  TypeGuard(Box, data);
  return data.valueOf();
};

Phew! Finally we are done. Our code should now look like this:

const TypeGuard = (ExpectedType, data) => {
  if (!ExpectedType) {
    throw new ReferenceError('Expected a Type as first argument');
  }

  if (
    data === undefined
    || data === null
    || !(data instanceof ExpectedType)
  ) {
    let datatype;
    switch (true) {
      case (data === undefined):
        datatype = undefined;
        break;
      case (data === null):
        datatype = null;
        break;
      default:
        datatype = data.constructor ? data.constructor.name : data;
    }
    throw new TypeError(`${datatype} is not of type ${ExpectedType.name}`);
  }
};

const _boxShake = (data) => {
  TypeGuard(Box, data);
  console.log(`Box(...)`);
  return data;
};
const _boxPeek = (data) => {
  TypeGuard(Box, data);
  console.log(`Box(${data.valueOf()})`);
  return data;
};
const _boxAdd = (data, val) => {
  TypeGuard(Box, data);
  if (isNaN(val) && typeof val !== 'string') {
    throw new TypeError(`Expected either a string or number type but got ${typeof val}`);
  }
  return Box(`${data.valueOf()}${val}`);
};
const _boxOpen = (data) => {
  TypeGuard(Box, data);
  return data.valueOf();
};

const Box = (x) => ({
  __typeclass: 'Box',
  valueOf: () => x,
  shake: () => _boxShake(Box(x)),
  peek: () => _boxPeek(Box(x)),
  add: (val) => _boxAdd(Box(x), val),
  open: () => _boxOpen(Box(x)),
});

Object.defineProperties(Box, {
  shake: { value: _boxShake },
  peek: { value: _boxPeek },
  add: { value: _boxAdd },
  open: { value: _boxOpen },
});

Object.defineProperty(Box, Symbol.hasInstance, {
  value: (instance) => instance['__typeclass'] === 'Box',
});

Our very own, custom Type Class in JavaScript! And there's no stopping there, you can reuse that type guard function and try the same pattern for other types, perhaps a List, or a Maybe? The IDE is your oyster 🦪

You can find the finished code in this gist.
I also have a repo which I'll likely add some more Type Classes to over time if you're interested.

If you have any questions of feedback feel free to comment 🙂

Happy coding λ

Posted on by:

Discussion

markdown guide
 

Ryan and where is polimorhism in this construct 😉 ?

 

Hey Maciej, thanks for the comment.
In a type class the form of polymorphism is ad-hoc polymorphism which uses parametric polymorphism while specifying the supported types, pretty much like overloading but you're "allowed" to set type constraints. For example a type class T can have a method concat that accepts only strings and arrays and that would be a valid type class.

That said, you are absolutely correct! I didn't put an example of any polymorphism in this. My bad for getting excited and writing this on a Sunday evening.
I've update it and cited you. Thanks 👍