Porting functional JavaScript to TypeScript

November 15, 2014

After playing a bit with option and validation in JavaScript, I thought it might be interesting to rewrite them in TypeScript. I was interested in how strong typing would impact the process of writing code, and I am especially interested in how it affects reading code.

Option

In JavaScript, option is implemented using two functions:

function some(x) {
  return {
      empty   : false
    , map     : function(f) { return some(f(x)); }
    , flatMap : function(f) { return f(x); }
    , ap      : function(a) { return a.map(x); }
  };
}

function none() {
  return {
      empty   : true
    , map     : function(f) { return this; }
    , flatMap : function(f) { return this; }
    , ap      : function(a) { return this; }
  };
}

Pretty straightforward, except that the reader has to imagine a lot about the characteristics of x, f, and a.

There's also some sneakiness going on with the implementation of some#ap, which passes x to the map function of a; this assumes that x is not just any old value, but is specifically a unary function that operates on the type of value represented by a.

Our JavaScript impelentation works, but it takes a lot of finger-crossing by a user to make it work.

In TypeScript, we can extract an Option interface, and implement it in two classes:

interface Option<A> {
  empty   : boolean;
  map     : <B>(f: (A) => B) => Option<B>;
  flatMap : <B>(f: (A) => Option<B>) => Option<B>;
  ap      : <B>(f: Option<A>) => Option<B>;
}

class Some<A> implements Option<A> {
  x: A;
  constructor(x: A) { this.x = x; }
  empty    = false;
  map      = function <B>(f: (A) => B) { return new Some(f(this.x)); };
  flatMap  = function <B>(f: (A) => Option<B>) { return f(this.x); };
  ap       = function <B>(a: Option<A>) { return a.map(this.x); };
  toString = function () { return 'Some(' + this.x.toString() + ')'; };
}

class None<A> implements Option<A> {
  empty    = true;
  map      = function <B>(f: (A) => B) { return new None(); };
  flatMap  = function <B>(f: (A) => Option<B>) { return new None(); };
  ap       = function <B>(a: Option<A>) { return new None(); };
  toString = function () { return 'None()'; };
}

Now we have strict enforcement of the types of x, f, and a except for the same Some#ap sneakiness we saw before:

ap = function <B>(a: Option<A>) { return a.map(this.x); };

Again we must wave our hands, and assume that x has the right type, i.e. (A) ==> B, to be passed to the map function of a. Frankly I'm surprised this even compiles, and I don't yet understand TypeScript's compiler well enough to explain why we can get away with it.

We can take it for a spin with a few examples:

var multiply: (x: number) => (y: number) => number =
  function(x) { return function(y) { return x * y; }; };

var x: Option<number> = new Some(21);
var a: Option<number> = x.map(multiply(2));
console.log(a.toString()); // Some(42)

var y: Option<number> = new Some(2);
var b: Option<number> = new Some(multiply).ap(x).ap(y);
console.log(b.toString()); // Some(42)

var z: Option<number> = new None();
var c: Option<number> = z.map(multiply(2));
console.log(c.toString()); // None()
var d: Option<number> = new Some(multiply).ap(z).ap(y);
console.log(d.toString()); // None()

Validation

In Javascript validation, like option, is implemented using two functions:

function valid(value) {
  return {
      valid    : true
    , value    : value
    , map      : function(f) { return valid(f(value)); }
    , flatMap  : function(f) { return f(value); }
    , ap       : function(a) { return a.map(value); }
  };
}

function invalid(errors) {
  return {
      valid    : false
    , errors   : errors
    , map      : function(f) { return this; }
    , flatMap  : function(f) { return this; }
    , ap       : function(a) {
                   if (a.valid) { return this; }
                   else { return invalid(this.errors.concat(a.errors)); }
                 }
  };
}

In TypeScript, we can extract an Validation interface, and implement it in two classes:

interface Validation<A> {
  valid   : boolean;
  map     : <B>(f: (A) => B) => Validation<B>;
  flatMap : <B>(f: (A) => Validation<B>) => Validation<B>;
  ap      : <B>(f: Validation<A>) => Validation<B>;
}

class ValidationError {
  name   : string;
  reason : string;
  constructor(name: string, reason: string) {
    this.name = name;
    this.reason = reason;
  }
}

class Valid<A> implements Validation<A> {
  x: A;
  constructor(x: A) { this.x = x; }
  valid    = true;
  map      = function <B>(f: (A) => B) { return new Valid(f(this.x)); };
  flatMap  = function <B>(f: (A) => Validation<B>) { return f(this.x); };
  ap       = function <B>(a: Validation<A>) { return a.map(this.x); };
  toString = function () { return 'Valid(' + this.x.toString() + ')'; };
}

class Invalid<A> implements Validation<A> {
  errors: ValidationError[];
  constructor(errors: ValidationError[]) { this.errors = errors; }
  valid    = false;
  map      = function <B>(f: (A) => B) { return this; };
  flatMap  = function <B>(f: (A) => Validation<B>) { return this; };
  ap       = function <B>(a: Validation<A>) {
               if (a.valid) { return this; }
               else { return new Invalid(this.errors.concat((<Invalid<A>>a).errors)); }
             };
  toString = function () {
               var str = '';
               for (var i = 0; i < this.errors.length; i++) {
                 var error = this.errors[i];
                 if (i > 0) { str = str + ','; }
                 str = str + error.name + ' ' + error.reason;
               }
               return 'Invalid(' + str + ')';
             };
}

This is nearly the same pattern we saw when porting option, with the same hand-wavy trouble in Valid#ap, The main difference here is the introduction of ValidationError, which is tracked as an array in Invalid#errors.

As before, let's test drive it with some examples:

function nonempty(name: string, value: string): Validation<string> {
  if (value && value.length > 0) {
    return new Valid(value);
  } else {
    return new Invalid( [ new ValidationError(name, 'must not be empty') ] );
  }
}

function mmddyyyy(name: string, value: string): Validation<string> {
  if (value && value.match(/^\d{2}\/\d{2}\/\d{4}$/) != null) {
    return new Valid(value);
  } else {
    return new Invalid( [ new ValidationError(name, 'must be formatted as mm/dd/yyyy') ] );
  }
}

function userAtDomain(name: string, value: string): Validation<string> {
  if (value && value.match(/^[^@]+@[^@]+$/) != null) {
    return new Valid(value);
  } else {
    return new Invalid( [ new ValidationError(name, 'must be formatted as user@domain') ] );
  }
}

var nameV: Validation<string>  = nonempty('name', 'Joe');
var emailV: Validation<string> = userAtDomain('email', 'foo@bar');
var dobV: Validation<string>   = mmddyyyy('dob', '11/22/3333');

console.log(nameV.toString()); // Valid(Joe)
console.log(emailV.toString()); // Valid(foo@bar)
console.log(dobV.toString()); // Valid(11/22/3333)

var nameI: Validation<string>  = nonempty('name', '');
var emailI: Validation<string> = userAtDomain('email', null);
var dobI: Validation<string>   = mmddyyyy('dob', '11/22/33');

console.log(nameI.toString()); // Invalid(name must not be empty)
console.log(emailI.toString()); // Invalid(email must be formatted as user@domain)
console.log(dobI.toString()); // Invalid(dob must be formatted as mm/dd/yyyy)

class User {
  name   : string;
  email : string;
  dob : string;
  constructor(name: string, email: string, dob: string) {
    this.name = name;
    this.email = email;
    this.dob = dob;
  }
  toString: () => string =
    function () {
      return 'user(name: '  + this.name +
                ', email: ' + this.email +
                ', dob:'    + this.dob + ')';
    };
}

function user(name: string) {
  return function (email: string) {
    return function (dob: string) {
      return new User(name, email, dob);
    };
  };
}

var userV: Validation<User> = new Valid(user).ap(nameV).ap(emailV).ap(dobV);
console.log(userV.toString()); // Valid(user(name: Joe, email: foo@bar, dob:11/22/3333)

var userI: Validation<User> = new Valid(user).ap(nameI).ap(emailI).ap(dobI);
console.log(userI.toString()); // Invalid(
                               //     name must not be empty
                               //   , email must be formatted as user@domain
                               //   , dob must be formatted as mm/dd/yyyy
                               // )

It's too early for me to conclude whether the type annotations make the code easier on the reader, but I did find that while writing the code, I had increased confidence in its correctness, and had decreased time needed to detect, diagnose, and repair bugs as I went. Gone also was my development/testing loop, replaced by a tighter development/compiling loop.