Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conditional (restricted) type class instances #3

Open
Avaq opened this issue Feb 23, 2018 · 24 comments
Open

Conditional (restricted) type class instances #3

Avaq opened this issue Feb 23, 2018 · 24 comments

Comments

@Avaq
Copy link

Avaq commented Feb 23, 2018

In Haskell, you can implemented an instance for a subset of values, by restricting the type variable, like so:

instance Monoid a => Monoid (Maybe a) where mconcat = impl

In JavaScript, using Fantasy Land, we've taken to use conditionally assigned properties to achieve a similar effect, eg:

const isSemigroup = x => typeof x['fantasy-land/concat'] === 'function'

Maybe.Just = a => ({
  value: a,
  tag: 'just',
  'fantasy-land/concat': isSemigroup(a) ? impl : null
})

I believe that switching from individually assigned properties that each correspond to a single algebra, to using a single property that points to a mapping of all implemented algebra's would make this practice less convenient at the least.

Maybe.Just = a => ({
  value: a,
  tag: 'just',
  'fantasy-land/canonical':
    isSemigroup(a) && isSetoid(a) && isOrd(a) ?
    MaybeWithSemigroupAndSetoidAndOrd :
    isSemigroup(a) && isSetoid(a) && !isOrd(a) ?
    MaybeWithSemigroupAndSetoid :
    //... we need the whole matrix ...
    PlainOldMaybe
})

Now for some thoughts on possible solutions. It makes sense to do something like this:

const isSimigroup = x => typeof x['fantasy-land/canonical'] === 'object' &&
                         typeof x['fantasy-land/canonical'].concat === 'function';

const Maybe = {
  concat: (a, b) => {
    if(a.tag === 'nothing' || b.tag === 'nothing')
      return Maybe.Nothing
    if(isSemigroup(a.value) && isSemigroup(b.value))
      return Maybe.Just(a.value['fantasy-land/canonical'].concat(a.value, b.value))
    throw new Error('Values must be Semigroups')
  }
}

But with this approach, it's not possible to detect beforehand whether value that use Maybe as their canonical module actually have an instance of Semigroup, which is very useful for run-time type checking. So perhaps this calls for keeping this data available somehow, for example:

const isSimigroup = x => Array.isArray(x['fantasy-land/instances']) &&
                         x['fantasy-land/instances'].includes('Semigroup');

const Maybe.Just = a => ({
  value: a,
  'fantasy-land/canonical': Maybe,
  'fantasy-land/instances': ['Functor', ...(isSemigroup(a) ? ['Semigroup'] : [])]
})

I'm also curious how one would write TypeScript definitions for these restricted instances, but I don't know enough about it.

@rpominov
Copy link
Owner

rpominov commented Feb 23, 2018

I like the idea of fantasy-land/instances property. Semantics sounds reasonable: the canonical module has the concat method, but don't use it on this particular value, this value doesn't support it because of the type of the value inside.

Also a thought on ideas like this: if we'll find a solution that is not breaking and can be added later, after the merge, I would rather don't include it in the big PR. That PR is going to be controversial, and all features like this will make it even more so. This might slow down the process.

On the other hand, if we find a better solution, but a breaking one. Maybe we should include it, to have all breaking changes in a single version update.

@davidchambers
Copy link

const isSemigroup = x => typeof x['fantasy-land/concat'] === 'function'

This may or may not be relevant to the proposal, but to support built-in types one should use Z.Semigroup.test.

@Avaq
Copy link
Author

Avaq commented Feb 23, 2018

@davidchambers I simplified the examples to exclude builtins for illustrative purposes. The idea remains the same.

@paldepind
Copy link

@rpominov

Also, not to argue for keeping /canonical postfix, but I don't understand how thunks related to fantasy-land/instances proposed in #3 .

Sorry, I didn't explain that properly at all. I'll reply in this thread as that seems to be a more appropriate place.

I think the approach I suggested in #2 (comment) can also solve the problem in this issue.
The first example @Avaq posted above can be implemented as follows:

Maybe.Just = a => ({
  value: a,
  tag: 'just',
  get ["fantasy-land/canonical"]() {
    return {
      concat: isSemigroup(a) ? impl : null
    };
  }
});

In other words, if an implementation needs a dynamic module then it can use a getter and have complete freedom.

An alternative way would be to do:

Maybe.Just = a => ({
  value: a,
  tag: 'just',
  'fantasy-land/canonical': {
    concat: isSemigroup(a) ? impl : null
  }
});

Which is very close to the original example. I'm curious what you think about that @Avaq?

@rpominov
Copy link
Owner

Ah, now I understand. Although this would be against following requirement from specification:

In case a value has a reference to a canonical module, that module must produce values with references to itself.

This basically means that x['fantasy-land/canonical'].of(whatever)['fantasy-land/canonical'] must be equal to x['fantasy-land/canonical']. Maybe we should change "equal" to "equivalent", btw.

Also I can't remember why I've added this requirement at all 😅
I'll try to remember that and get back to you, otherwise we should probably consider removing it.

@rpominov
Copy link
Owner

rpominov commented May 1, 2018

Ok, so without this requirement following would be allowed:

  1. Produced value may not have the canonical module at all.
  2. Produced value may have a completely different canonical module.

This will make generic code unreliable. For example lift2(f, a, map(g, a)) should be always equivalent to lift2((x, y) => f(y, x), map(g, a), a), but it won't be if map produces incompatible value. For lift2 and map defined like that:

function map(f, v) {
  return v['fantasy-land/canonical'].map(f, v)
}

function lift2(f, v1, v2) {
  const A = v1['fantasy-land/canonical']
  return A.ap(A.map(x => y => f(x, y), v1), v2)
}

Although I see that this requirement blocks a particular technique to implement restricted type class instances. The one when we enforce type safety by removing certain methods. But maybe it's not a good idea to enforce type safety like that. For example here it won't work anyway:

const a = Maybe.of(List(1, 2, 3))
const b = Maybe.of(Text('123'))

if (a['fantasy-land/canonical'].concat && b['fantasy-land/canonical'].concat) {
  a['fantasy-land/canonical'].concat(a, b) // Boom!
}

In this example we would probably get an exception form List.concat saying that second argument isn't a List. So if we use method removal technique sometimes we detect unsupported values early, and sometimes we get an exception later. Maybe it's would be better to always use exceptions?

@paldepind
Copy link

@rpominov I think you have a lot of good observations @rpominov.

Although this would be against following requirement from specification:

As far as I can see the other solution that relied on fantasy-land/instances also went against that rule?

Ok, so without this requirement following would be allowed:

I think something needs to done to improve the rule while still disallowing those things. The rule is a bit vague as of right now. For instance, it's a unclear what "values" exactly means in "that module must produce values with references to itself."

We want to say that map should return a value with an equivalent module. But, clearly reduce does not have to return a value with an equivalent module. So being more specific about what should and shouldn't have the same module would probably be better. I don't have any good ideas about how to do that though 😢.

For example lift2(f, a, map(g, a)) should be always equivalent to lift2((x, y) => f(y, x), map(g, a), a), but it won't be if map produces incompatible value.

In this specific example, doesn't the functor law prevent that?

@rpominov
Copy link
Owner

rpominov commented May 1, 2018

As far as I can see the other solution that relied on fantasy-land/instances also went against that rule?

No, we could have fantasy-land/canonical that never change, plus fantasy-land/instances that changes from value to value.

But, clearly reduce does not have to return a value with an equivalent module.

Good point, would be great to clarify this. Sometimes it's hard to manage balance between strictness and clarity. When you try to write something 100% correctly, you end up with a simple concept described in a complex way. Maybe we could rely on common sense here?

In this specific example, doesn't the functor law prevent that?

Algebra laws currently only concerned with modules, and don't say anything about fantasy-land/canonical. So a['fantasy-land/canonical'] and map(f, a)['fantasy-land/canonical'] both must be valid modules independently (obey the Functor laws, etc.), but they could be different modules. And which one is used may depend on arguments order.

@paldepind
Copy link

No, we could have fantasy-land/canonical that never change, plus fantasy-land/instances that changes from value to value.

True, but that seems to introduce unnecessary complexity when the value could instead just only provide the functions that it actually supports. It seems confusing to at the same time have a concat function but then, in some different place, claim not to be a semigroup.

So a['fantasy-land/canonical'] and map(f, a)['fantasy-land/canonical'] both must be valid modules independently (obey the Functor laws, etc.), but they could be different modules.

How so? The first functor law states that F.map(x => x, a) ≡ a. So when given the identity function map must return an equivalent value which implies that it must also have an equivalent canonical module. To me, that seems to make it impossible for the map to change the canonical module.

The one when we enforce type safety by removing certain methods. But maybe it's not a good idea to enforce type safety like that. For example here it won't work anyway:

The more I think about it the more I agree with this sentiment. Besides the problem you mention there is another problem as well. Let's say you want to create a Maybe that only is a semigroup when the value inside is a semigroup (the original example). Then what do you do when map is called on the Just? The mapping function may change wether or not the value inside the Just is a semigroup. But to know that you have to inspect the value returned by the mapping function which goes against the current specification which states:

No parts of f's return value should be checked.

So it seems to me that the current specification doesn't allow for such "dynamic methods" either.

@rpominov
Copy link
Owner

rpominov commented May 1, 2018

How so? The first functor law states that F.map(x => x, a) ≡ a. So when given the identity function map must return an equivalent value which implies that it must also have an equivalent canonical module. To me, that seems to make it impossible for the map to change the canonical module.

But in my example it was arbitrary function f, not x => x. Also I'm not sure that the definition of equality automatically includes equality of canonical modules. Probably yes, but it can be arguable. Although with the restriction from #3 (comment) it's clear.

@davidchambers
Copy link

Let's say you want to create a Maybe that only is a semigroup when the value inside is a semigroup (the original example). Then what do you do when map is called on the Just? The mapping function may change wether or not the value inside the Just is a semigroup.

The approach we took with Sanctuary is to define the method conditionally:

//  Add "fantasy-land/concat" method conditionally so that Just('abc')
//  satisfies the requirements of Semigroup but Just(123) does not.
if (this.isNothing || Z.Semigroup.test(this.value)) {
  this['fantasy-land/concat'] = Maybe$prototype$concat;
}

This ensures that Z.Semigroup.test (S.Just ('abc')) evaluates true but Z.Semigroup.test (S.Just (123)) evaluates false.

@paldepind
Copy link

@rpominov

But in my example it was arbitrary function f, not x => x.

But the functor doesn't know if it is given the identity function or some other function. That's part of what makes that law so powerful. If the functor cannot mess with the canonical module when given the identity function then it cannot mess with the canonical module when given any function.

@davidchambers

The approach we took with Sanctuary is to define the method conditionally:

My point was exactly that that approach does not work with the current spec. Consider this example.

const a = S.Just(123);
const b = a["fantasy-land/map"](_) => "abc");

If a is not a semigroup but b is then you have determined that by inspecting "abc". And the functor spec states:

No parts of f's return value should be checked.

So the approach is not compliant with the spec. When I map over a Maybe it's not permitted to check the value my function returns. Checking if the value is a semigroup is not allowed.

@davidchambers
Copy link

Did you mean to use fantasy-land/concat rather than fantasy-land/map in your example, Simon?

@paldepind
Copy link

@davidchambers I don't think so? Does is not make sense?

@davidchambers
Copy link

Oh, I think I see your point now. Are you showing that it's problematic for Z.Semigroup.test (S.Just ('abc')) to evaluate true (whereas Z.Semigroup.test (S.Just (123)) evaluates false) because in the expression S.map (_ => 'abc') (S.Just (123)) we're inspecting 'abc' to make a decision about the capabilities of the object which wraps it?

@paldepind
Copy link

Yes, that is exactly what I meant 😄

@davidchambers
Copy link

Cool. It just took me a while to catch up with you. :)

I don't have a problem with breaking the letter of the law (by inspecting the value passed to S.Just) because I believe we're following the spirit of law. We're not changing the behaviour of any well-typed program; we're just providing better error reporting in the case of a type error.

@rpominov
Copy link
Owner

rpominov commented May 4, 2018

@davidchambers

we're just providing better error reporting in the case of a type error.

Is it better though? If programmer made a mistake and wrote something like Z.concat(S.Just(1), S.Just(2)), do they get infamous "undefined is not a function"? Would't it be better if Maybe always implemented concat, and threw an exception with a good message on an application with a wrong value inside?

@rpominov
Copy link
Owner

rpominov commented May 5, 2018

@davidchambers I've probably tested an older version of sanctuary before (found some REPL online), just tested with the latest version. First of all, man, these error messages are the best!

S.concat(S.Just([]), S.Just(''))
TypeError: Type-variable constraint violation

concat :: Semigroup a => a -> a -> a
                         ^    ^
                         1    2

1)  Just([]) :: Maybe (Array b)

2)  Just("") :: Maybe String, Maybe RegexFlags

Since there is no type of which all the above values are members, the type-variable constraint has been violated.

Seems like you handle all cases I was worried about perfectly. Although I've noticed that it seems like you don't need to remove the fantasy-land/concat method anyway. For instance, in the example above you don't rely on it being removed, you're able to detect error even though it's present on both arguments. I still wonder in what cases it's usable to not have fantasy-land/concat method? Can you give an example? I'm just trying to understand whether we block an important use case with the requirement mentioned in #3 (comment) .

@davidchambers
Copy link

I appreciate the positive feedback, @rpominov. The error messages are not nearly as friendly as Elm's (sanctuary-js/sanctuary-def#150), but they're usually quite informative.

I still wonder in what cases it's usable to not have fantasy-land/concat method? Can you give an example?

Prior to sanctuary-js/sanctuary#359, Sanctuary would permit S.concat (S.Just (1)) (S.Just (1)), which would throw an error with Semigroup.methods.concat(...) is not a function as its message.

By attaching fantasy-land/concat conditionally, Z.Semigroup.test is able to detect type errors early, allowing much better error messages:

S.concat (S.Just (1));
// ! Type-class constraint violation
//
//   concat :: Semigroup a => a -> a -> a
//             ^^^^^^^^^^^    ^
//                            1
//
//   1)  Just(1) :: Maybe Number, Maybe FiniteNumber, Maybe NonZeroFiniteNumber, Maybe Integer, Maybe NonNegativeInteger, Maybe ValidNumber
//
//   ‘concat’ requires ‘a’ to satisfy the Semigroup type-class constraint; the value at position 1 does not.
//
//   See https://github.com/sanctuary-js/sanctuary-type-classes/tree/v7.1.1#Semigroup for information about the sanctuary-type-classes/Semigroup type class.

@rpominov
Copy link
Owner

rpominov commented May 5, 2018

@davidchambers would it be possible to provide same error early by looking at the type Maybe Number, Maybe FiniteNumber ... instead of checking for the method? I guess it would be far less elegant though, even if possible at all :(

I just realized another issue we'll face if we allow module to change from value to value. We wouldn't be able to reuse a module. For example we would have to rewrite this

function lift2(f, v1, v2) {
  const A = v1['fantasy-land/canonical']
  return A.ap(A.map(x => y => f(x, y), v1), v2)
}

to something like this:

function lift2(f, v1, v2) {
  const mapped = v1['fantasy-land/canonical'].map(x => y => f(x, y), v1)
  return mapped['fantasy-land/canonical'].ap(mapped, v2)
}

Also in case when a method takes more than one value we would need to somehow decide which canonical module to use e.g., do I do mapped['fantasy-land/canonical'].ap(mapped, v2) or v2['fantasy-land/canonical'].ap(mapped, v2) ?

Alternative solution suggested by @Avaq solves this issue.

@davidchambers
Copy link

@davidchambers would it be possible to provide same error early by looking at the type Maybe Number, Maybe FiniteNumber ... instead of checking for the method?

The term type class is a misnomer in the Sanctuary world. We don't really have types, so we can't make groups of types. We do have values, though, and we can fake type classes by inspecting values (as well as making allowances for values of built-in “types”).

@masaeedu
Copy link

masaeedu commented Jul 22, 2018

The way I would write the following instance:

instance Monoid a => Monoid (Maybe a) where mconcat = impl

with a static-land like approach would be:

const Maybe = (() => {
  // ADT
  const Nothing = { hasValue: false };
  const Just = x => ({ hasValue: true, x });
  const match = ({ Nothing, Just }) => ({ hasValue, x }) =>
    hasValue ? Just(x) : Nothing;

  // We don't try to put all possible instances in here, just the ones that apply to all Maybes, like
  const of = Just;
  const chain = f => match({ Nothing, Just: f });

  return { Nothing, Just, match, of, chain };
})();

// Destructure for convenience
const { Nothing, Just, match } = Maybe;

// The universe of instances is open, and you can always write more dictionaries
// instance (Semigroup s) => Monoid (Maybe s) where ...
const Maybenoid = S => ({
  empty: Nothing,
  concat: match({
    Nothing: y => y,
    Just: x =>
      match({
        Nothing: Just(x),
        Just: y => Just(S.concat(x)(y))
      })
  })
});

const IntSum = { empty: 0, concat: x => y => x + y }

console.log(Maybenoid(IntSum).concat(Just(1))(Just(2))) // => Just(3)
console.log(Maybenoid(IntSum).concat(Nothing)(Just(2))) // => Just(2)

@masaeedu
Copy link

There's other ways to have a Monoid for Maybe depending on what precise inner monoid you're talking about. For example they have the First and Last monoids for Maybe in Haskell:

const Fn = { id: x => x, flip: f => a => b => f(b)(a) };

// Handy for flipping semigroups around
const Dual = M => ({ ...M, concat: Fn.flip(M.concat) });

// the First semigroup just drops the second value
const First = { concat: x => y => x };
// the Last semigroup does the opposite
const Last = Dual(First);

const Arr = (() => {
  const foldMap = M => f => arr =>
    arr.reduce((p, c) => M.concat(p)(f(c)), M.empty);
  const fold = M => foldMap(M)(Fn.id);

  return { foldMap, fold };
})();

// Both of them can be plugged into Maybenoid to get interesting instances
const tests = [
  Arr.fold (Maybenoid(First)) ([]),                          // => Nothing
  Arr.fold (Maybenoid(First)) ([Just(1), Nothing, Just(2)]), // => Just(1)
  Arr.fold (Maybenoid(Last))  ([]),                          // => Nothing
  Arr.fold (Maybenoid(Last))  ([Just(1), Nothing, Just(2)])  // => Just(2)
];

console.log(tests);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants