paldepind / union-type

A small JavaScript library for defining and using union types.
MIT License
477 stars 28 forks source link

Prototype based version #18

Closed alexeygolev closed 8 years ago

alexeygolev commented 9 years ago

I like how ramda team uses issues to bounce possible ideas off each other so figured we can try to do the same. I really like the lib overall but I think there are some things that could be improved. So here is an idea (I left the unchanged parts)

function Constructor(group, name, validators) {
  //this way our actual Type will be a prototype of our new type
  let cons = Object.create(group, {
    //keep the cobstructor name around (you can't mutate it)
    _ctor: {
      value: name
    },
    //be nice about presentation (poor man's `deriving(Show)`
    toString: {
      value: function(){
        return `${name}(${this.value.toString()})`
      }
    }
  });
  return curryN(validators.length, function() {
    //Same prototype magic to only expose our values
    var val = Object.create(cons), validator, i, v;

    //this is the only thing that I find a bit annoying
    //because we can't subclass an Array(it's in ES6 but it can't really be solved with a transpiler) 
    //we can't really have a neat proto chain
    //and values as an array without putting them into a property:(
    val.value = [];
    for (i = 0; i < arguments.length; ++i) {
      v = arguments[i];
      validator = mapConstrToFn(group, validators[i]);
      if ((typeof validator === 'function' && validator(v)) ||
          //we only need to check our _ctor now
        (v !== undefined && v !== null && v._ctor in validator)) {
        val.value[i] = v;
      } else {
        //this will print a nice error for primitive types only:(
        throw new TypeError(`Couldn't match expected type ${validators[i].name || validators[i]} with actual type ${typeof v}. Expression ${name}(${v})`);
      }
    }
    return val;
  });
}

function rawCase(type, cases, action, arg) {
  // we only need to check if our action is actually OUR action
  if (!type.isPrototypeOf(action)) throw new TypeError(`Data constructor ${action.name} is not in scope`);
  //and then just use our _ctor to get the case we need
  var _case = cases[action._ctor] || cases['_'];
  if (!_case) {
    throw new Error('Non-exhaustive patterns in a function');
  }
  //we need to use .value here to account for the API change
  return _case.apply(undefined, arg !== undefined ? action.value.concat([arg]) : action.value);
}

var typeCase = curryN(3, rawCase);
var caseOn = curryN(4, rawCase);

function Type(desc) {
  var obj = {};
  //for loop might be more performant(if you do care...I'm not that bothered) but then we need to do a 'hasOwnProperty' thing
  // ...or
  Object.keys(desc).forEach( key => {
    obj[key] = Constructor(obj, key, desc[key]);
  });
  //Rename case to caseOf to calm down the linters (case is a reserved word) 
  obj.caseOf = typeCase(obj);
  obj.caseOn = caseOn(obj);
  return obj;
}

This way we basically only have values on our type instances instead of heaving everything on every instance. Thoughts?

alexeygolev commented 9 years ago

That should probably go into separate issue but I don't want to flood. I sketched out some additional things that could be useful. I haven't cleaned up the code. Wanted to get some feedback first. here is a gist Using it in this form in one of the 'non-toy' projects (don't tell anybody:))) PS. all the old tests are passing (after adding some api changes like .value and caseOf as well as some error RegExps)

paldepind commented 9 years ago

This seems ambiguous to me. We passed an object to describe the fields so they can have no order?

alexeygolev commented 9 years ago

@paldepind see the discussion here... it depends on browser implementation but as this suggests that it's in the specification now. So we can rely on the order properties were defined (we can put a disclaimer). Haskell does it the same way.

data Person = Person {
    personName :: [Char]
   ,personId :: Int
} deriving(Show)

person1 :: Person
person1  = Person { personName = "John", personId = 1}

person2 :: Person
person2  = Person "Johnny" 2

Technically to align it with haskell we could create accessors for each property in the record (person.name(), person.id())

paldepind commented 9 years ago

@alexeygolev Cool. I knew that browsers keep the property order. But I didn't know that relying on it was "allowed".

But what about this. It seem ambiguous as well. How does one know if this is an attempt at partially applying the value constructor or a wish to create a value from an object?

I think these ideas are really interesting and they seem very useful.

alexeygolev commented 9 years ago

@paldepind Well it is kind of allowed but I guess adding a disclaimer would be nice as well. This way the lib user knows that there is something that needs attention. As for

let person2 = Person({id: 2, name: "Johnny"});

I think this is quite simple to reason about. If one defines his type using record syntax as in {key:String, anotherKey:String} he can expect to create a value of this type either by using the same record syntax, or by using a simple way of just passing values in the order she defined the keys.

I found this really useful in Haskell. Sometimes your record values will be just two fields so it's handy to just use the simple notation. But sometimes it's easier to use the object notation. Notice though that I made sure that if you use record syntax to construct a value you can pass the keys in any order. Same way it is done in Haskell. What I really like about this approach is that if we use records this way and have an ability to pass functions that will operate on the type we're defining, we can do things like this. Or, more importantly, make it possible for the lib user to do things like this if the wish so. Main api for accessing the value will still be accessing through an array. This way we're keeping a uniform consistent api for the lib but give the tools to extend it if needed. See the Maybe example...we basically have all the tools to create new monads without all the constructor functions, prototypes and new madness.

paldepind commented 9 years ago

I think this is quite simple to reason about. If one defines his type using record syntax as in {key:String, anotherKey:String} he can expect to create a value of this type either by using the same record syntax, or by using a simple way of just passing values in the order she defined the keys.

The problem is. If you create a type likes this: const Person = Type({Person:{parent: R.T, name: String, id: Number}). Then you can instance it likes this:

var person = Person.Person(parent, name, id)

But since the value constructor is curried you could also do this:

var personFromId = Person.Person(parent, name)

And get back a constructor that only requires an id. But then, when you do this, ambiguity arises:

var personWithParent = Person.Person(parent)

How does the library know if this is an attempt at partially applying Parent or an attempt to create a value from an object? We could use magic and simply treat any object with all the necessary keys as a value creation. But it still seems a bit unclean to me. Maybe having two different function would be better?

I found this really useful in Haskell. Sometimes your record values will be just two fields so it's handy to just use the simple notation. But sometimes it's easier to use the object notation. Notice though that I made sure that if you use record syntax to construct a value you can pass the keys in any order. Same way it is done in Haskell.

Yes. I certainly agree. What union type gives you today is something similar to Haskells data type definition. With your proposal it would also achieve the same power as Haskells records.

What I really like about this approach is that if we use records this way and have an ability to pass functions that will operate on the type we're defining, we can do things like this. Or, more importantly, make it possible for the lib user to do things like this if the wish so. Main api for accessing the value will still be accessing through an array. This way we're keeping a uniform consistent api for the lib but give the tools to extend it if needed. See the Maybe example...we basically have all the tools to create new monads without all the constructor functions, prototypes and new madness.

Yes. This is certainly a nice feature. The only thing that might be missing is a way to extend an existing type with new methods. But one can of course create new functions that will work on existing types so that might not be an actual issue.

alexeygolev commented 9 years ago

Now I get it. Sorry, completely misunderstood your previous question and went on about something else instead of properly answering it.

But since the value constructor is curried...

It's not

I have mixed feeling about partially applying the value constructor. I even catch one scenario of arguments length mismatch here. Would rather add a case for when we have less arguments than this type constructor expects.

But I see your point and if the aim is to align the ideas with Elm and Haskell we should support partial application. In Haskell, when using record syntax to construct a value you can't partially apply it. You either supply all the keys or you get the Fields of Person not initialised: personId.

I will sketch something out today. It will be a bit more complex than just curryN.

Yes. This is certainly a nice feature. The only thing that might be missing is a way to extend an existing type with new methods. But one can of course create new functions that will work on existing types so that might not be an actual issue.

What kind of extensions do you have in mind? My idea of passing an object with functions was to somewhat mimic a typeclasses idea. It would just make dynamically dispatched functions like map in ramda work in a way a type creator would like it to work.

paldepind commented 9 years ago

It's not

I did notice. But it currently is in union-type and I think it is appropriate.

I'm thinking that if a type is created based on a object instead of an array an extra constructor function could be exposed with some postfix added like this:

let {Person, PersonOf} = Type({Person:{name: String, id: Number}})
var person1 = Person('Simon', 13)
var person2 = PersonOf({name: 'Alexey', id: 14})

This seems like a simple solution to me. The {name}Of prefix is quite meaningless however, but I can't think of anything better.

What kind of extensions do you have in mind? My idea of passing an object with functions was to somewhat mimic a typeclasses idea. It would just make dynamically dispatched functions like map in ramda work in a way a type creator would like it to work.

Yes. I get that. I was thinking of a way to extend an existing type with new methods beyond those you initially add when you invoke Type. You could add a new methods to it's prototype (the cons object). But I don't actually think it's useful.

alexeygolev commented 9 years ago

@paldepind I updated the code here. So far it works for the things I'm throwing at it. So we don't really need an extra PersonOf constructor. One thing I wanted to discuss though. When calling value on a value constructed with a type of another constructor (see eye colours) should we expand the values for the types embedded or should we only expand one level?

paldepind commented 9 years ago

I think your ideas are awesome! But there are some things in the implementation that I am not too happy about.

Values are only accessible through value or _value. That is IMHO a big problem. If you create a type width some fields you expect to find the fields directly as properties of the type. I think that is a must for user friendliness and convenience.

I find the use of getters and setters confusing. Consider this example (it uses your latest version):

let {Person} = Type({Person: {name: String, id: Number}})
let me = Person({name: 'Simon', id: 13})
console.log(me.value) //=> ['Simon', 13]
// Natural expectation: I can set `value` with an array
me.value = ['Simon', 14]
console.log(me.value) //=> [undefined, undefined] – oops :(

So far it works for the things I'm throwing at it. So we don't really need an extra PersonOf constructor.

I still think we do. Consider this example (also using your latest version):

const T = () => true
let {Person} = Type({Person: {parent: T, name: String, id: Number}})
let father = Person(undefined, 'Knud', 12)
let createChild = Person(father) // Throws: TypeError: Fileds of Person not initialised: parent,name,id – oops
let me = createChild('Simon', 13)

Please take a look at this version in the "next" branch. It is my attempt at implementing your splendid ideas without sacrifices.

It has the following features:

One can do stuff like this:

function maybeMap(fn) {
  var that = this;
  return this.case({
    Nothing: this.Nothing,
    Just: function(v) { return that.Just(fn(v)); },
  }, this);
}
var Maybe = Type({Just: [T], Nothing: []}, {map: maybeMap})
var just1 = Maybe.Just(1);
var just4 = just1.map(add(3));

// Nested destructuring
const T = () => true
let {Person} = Type({Person: {parent: T, name: String, id: Number}})
let father = Person(undefined, 'Knud', 12)
let createChild = Person(father) // Throws: TypeError: Fileds of Person not initialised: parent,name,id – oops
let me = createChild('Simon', 13)
let {parent: {name: fatherName}} = me
console.log(fatherName) //=> 'Knud'

The biggest problem in my implementation is that when you create a value from an object the fields will be put both in numerical properties (0, 1, etc.) and in the field name properties (name, id, etc.). I think not keeping numerical properties for record-like-types is the solution.

My code also doesn't support adding methods specifically to the values or to the type object. You can only add to both. That is fixable as well.

I also haven't implemented your toString method. That is an omission (I forgot it).

Let me know what you think!

alexeygolev commented 9 years ago

mmm...Iterators, yummy. Going to play with your code and let you know! I just want to know if you like the Of for the API clarity or to avoid errors. I'm sure we can catch most of the scenarios like the one with undefined above. I propose we create a new issue for the next branch on continue there. This issue is growing way beyond the initial subject:)

paldepind commented 9 years ago

I just want to know if you like the Of for the API clarity or to avoid errors. I'm sure we can catch most of the scenarios like the one with undefined above.

Both. And there will always be unambiguouty. We can also properly curry from the get go. This makes it possible to use Ramdas placeholders.

I propose we create a new issue for the next branch on continue there. This issue is growing way beyond the initial subject:)

Good idea!