Wondering about Typeclasses

Either as a functor was an aside and my point in mentioning it was to say that I don’t use it, therefore I had no experience with it.

Honestly, fmap was a pretty bad example considering that it works fundamentally differently for many Functors.

A better example would be basically any of the magic types in Elm (number, appendable, etc). I feel like it would work a lot better to have these as typeclasses because number, for example, in its current state in nearly useless because there’s basically zero polymorphism. You can’t even do multiplication with say, a number and a Float, because the number might be an Int. Why is this an issue??

(To be clear, I’m not advocating for implicit casting. Just to be able to have a Num typeclass that lets me avoid these annoyances to some extent.)

I don’t follow. In the Functor typeclass, the map signature is defined and it must be followed in all instances. With <module>.map no such guarantee exists.

Implementing map weirdly, regardless of having the Functor fmap signature, is your own loss. It should be implemented in a way that makes sense for the data type. After all, if it’s not, users will just avoid it and possibly replace it with their own function (like I was getting at earlier with the Functor (Either a) implementation—I would never use it in code because I find it arbitrary and backwards—instead I would use mapLeft and mapRight from Bifunctor).

This is true, and I share the concern that in practice typeclass tend to group things that do not necessarily naturally fall into classes.

This comment was mostly in reference to subclassing, where you have to account for all possible future subclasses, but I think it still applies to typeclasses.

In my experience, polymorphic relationships built into code rarely model actual hierarchical relationships as they should be. Someone writes a function that operates over a typeclass, then someone wants to use that function with a type that doesn’t implement the typeclass, so they implement it on their type, even if it doesn’t entirely make sense. I’m sure this is how Either came to implement fmap. In Elm, there’s no temptation to implement fmap since you pass mapLeft or mapRight as appropriate.

You can think about Elm as having one typeclass per function, with an anonymous implementation being specified at the callsite. I like that this prevents the tendency to reuse existing typeclasses when they are not 100% appropriate.

They don’t group into classes but merely label. Fundamental difference.

Why there is Result.map and not Result.transoftmOk?

And yet we have extensible records which does exactly that. Heck, functional parameters specify less. This is a complete strawman.

1 Like

Well, Either does not implement map (or rather, Functor), instead, Either a for any type a implements Functor. Hence, map can’t act on Left because the type of Left components cannot change. (And it must act on Right because calling map f with a function f : b -> c where b and c are different does not type check otherwise.)

I don’t necessarily think that Elm needs type classes though. What it does need, in my opinion, is a more convincing solution to the problem of providing generic datatypes whose implementation requires some additional knowledge about the parameter type.

For example, using Dict with a custom type T as a key is a bit of a pain. I know the following solutions:

  1. Create a function toKey : T -> C with a comparable type C. You need to make sure that toKey is injective and you have to call it explicitly every time you do (basically) anything with your Dict.
  2. Create a module TDict where you copy (everything you need from) the Dict interface, except you add the calls to toKey there so you don’t have to do is at every usage site. This adds a lot of boilerplate. You also kind of have to use Dict under the hood if you you want equality to do something sensible on your type. (If you implement a red-black tree yourself, to trees containing the same elements (only inserted in a different order) might not be equal.)
  3. Use one of the generic dicts from the packages. These either safe the toKey function with the Dict (but saving function in the model is discouraged) or they implement the dict as a List (key, value), relying on the implicit equatable constraint every type has (but this is inefficient for large dicts and you have the problem of logically equal dicts not being structurally equal again).

I often go for solution 2 and accept the boilerplate (even though it does not feel helpful in any way).

Funnily enough, discouraging functions in the model is mostly due to them not being equatable. Maybe the best solution would be to move to “equaters” instead, i.e. a composable interface to describe equalities on types (similar to Decoders etc.). Or, for this specific problem, making custom types comparable (or maybe “hashable”) if all their fields are would also work.

<module>.map also has a signature that must be followed in all instances; it’s the same guarantee. Neither is more type-safe than the other.

It’s true that “if I label something a Functor, then I have the compile-time guarantee that it’s been labeled a Functor,” but adding that label doesn’t make my code any safer.

Typeclasses do have costs and benefits (e.g. increased complexity as a cost and code reuse as a benefit), but improved safety and guaranteeing how implementations work are not among them.

Agreed! The name of the function is what creates expectations about what it does, as usual.

If you put 2 * 0.2 into elm repl, it prints 0.4.

The 2 has the type number and the 0.2 has the type Float, so you can definitely do multiplication with a number and a Float in Elm!

If I type the following into the Elm repl

mult : number -> Float -> Float
mult a b = a * b

I get the following error message:

Multiplication does not work with this value:

3| mult a b = a * b
                  ^
This `b` value is a:

    Float

But (*) only works with Int and Float values.

(Which is surprisingly confusing, by the way, though the hint clears things up considerably.)

I happen to know why the one works and the other one doesn’t; but I can also understand being confused by it. Whether “You can multiply number and Float” is true or false depends on where you put the implicit quantifier for the type variable number; different contexts place it differently (they must: the same place might not even exist!).

2 Likes

Sure, but typeclasses don’t help with that.

Here’s the same function in Haskell, using the Num typeclass:

mult :: Num a => a -> Float -> Float
mult a b = a * b

Here’s what ghc says about it:

Mult.hs:2:16: error:
    • Couldn't match expected type ‘a’ with actual type ‘Float’
      ‘a’ is a rigid type variable bound by
        the type signature for:
          mult :: forall a. Num a => a -> Float -> Float
        at Mult.hs:1:1-36
    • In the second argument of ‘(*)’, namely ‘b’
      In the expression: a * b
      In an equation for ‘mult’: mult a b = a * b
    • Relevant bindings include
        a :: a (bound at Mult.hs:2:6)
        mult :: a -> Float -> Float (bound at Mult.hs:2:1)
  |
2 | mult a b = a * b
  |                ^

So going back to the original question:

This affects both Haskell and Elm equally, regardless of typeclasses, because both languages use Hindley-Milner type unification rather than subtyping.

Type unification lets you take a number -> number -> number (in Elm) or Num a => a -> a -> a (in Haskell) function and apply it with a Float for either argument, but it still considers a number -> Float -> Float function type-incompatible with a number -> number -> number one (and likewise, it considers a Num a => a -> Float -> Float function type-incompatible with Num a => a -> a -> a). Subtyping could change that, but allowing subtyping would seriously degrade compile times in either language.

Typeclasses don’t help here!

3 Likes

I don’t mind the current Elm choice of Type.map.

This is my main take away of what’s desirable but missing currently:

Is there another approach that Elm could take to obtain this? Does it have to be only a) Haskell Typeclasses or b) No Haskell Typeclasses?

Since Elm did forge its own path with semver enforcement in packages.

  • If we export a map with incompatible signatures, could it be rejected?
  • If we export a transform or fromElement, could they be strongly advised to use certain names?
  • Something else?
4 Likes

One approach that I think was suggested in a previous discussion is to allow custom types to implement the defined set of “typeclasses” (number, comparable, etc), but not to allow for creating custom type classes.

At least one problem with that: Unlike Haskell’s syntax, the magic type variables of Elm make it inconvenient to specify that a type should implement several type classes at once. There is compappend to express both comparable and appendable simultaneously but no other combinations. (Because number implies comparable, and number and appendable have no common types.) If custom types were to be admitted, you’d need to enforce these restrictions or create new ad-hoc combination classes.

(The magic type variables really are the most inelegant part of Elm. Haskell’s solution makes so much more sense.)

2 Likes

This is a good point, and a good solution would involve addressing that problem.

Another possible approach is to completely remove these variables and require the passing of things like String.compare to dictionary operations.

I think this would be a good idea:

  • number: we could easily have an extra set of operators say for floating point math (I think Gleam has .*, .+, etc)
  • appendable: I think if strings didn’t have an append operator that would be fine.
  • comparable: We would just have to add relevant functions Float.lessThen (elm-units already does it and the code is fine, if a bit verbose feeling).
1 Like

This is starting to sound like: Just for fun: What if there were no type classes at all?

I find it hard to remember everything I want to reply to so I think I’ll just make one post replying to all the new comments.

Results are used in situations where you do multiple operations, any of which may fail. If it failed you just pass on the failure. Therefore it makes sense that you operate on the Ok value, just as Maybe.map operates on the Just value.

Eithers do not have this because they are simply Left or Right—no connotation about which means what. It’s just confusing when Eithers are used when Results are more apt.

I agree with you here. Neither typeclasses or extensible records cause these problems, and they actually solve many problems that would be otherwise hard to solve without them. The argument has virtually no weight because in both cases, abusing the feature will just backfire on you and possible the users of your package (if you are making a package). Much better is to just think critically about whether you are using the typeclass/function accepting an extensible record for its intended purpose.

Am I missing something? Are you referring to the convention, or maybe to the fact that the code assuming the signature wouldn’t type-check? Because I don’t see any reason why I can’t export a map function from a module with whatever signature I like. If I’ve missed something fundamental please tell me!

I see your point. I think I missed the mark with typeclasses there. Though in my defense I wasn’t really referring to Haskell. I meant you would have a number typeclass that requires the implementation of say, toFloat, so you could have number be truly polymorphic: for Float it would be identity and for Int it would be the existing toFloat. That’s what I meant when I said number is basically useless.

Interesting idea though I think that it would be counterproductive. A better solution IMO would be to have simple, usage-oriented typeclasses like Mappable as opposed to the computer science mumbo-jumbo of Haskell.

100% agreed. It’s an abuse of the syntax for type variables.

Yes, it certainly is. I don’t want this conversation to transform into something about alternatives to typeclasses. I really just think a debate as to whether typeclasses are worth it should be the limit of this thread.

Ah, I was referring to the convention. I interpreted “all instances” as “all situations” rather than “all typeclass instances” - so I see what you mean now!

There are ways in Haskell to auto-derive Functor so that is probably not the best example.

Your are right that the code a library author has to write is probably largely the same but for the consumers of your library it is very different.

Not all abstractions are good or lead to readable code and Elm might have a point in disallowing a huge operator-zoo (lens anyone?) but there are a couple of by now rather obvious good abstractions and Functors are among them (there is a reason even in Elm you have maps everywhere)

1 Like

“The syntax for using the implementations” is more than just syntax. It actually lets multiple types pass through. A contrived example would be fmap show to convert a boxed type to a boxed string. I had issues with this type of thing when trying to write functions to act on numbers.

1 Like

This is more a lack of higher-kinded types than type classes, though. I would be fine with writing

mapIntsToStrings : ((a -> b) -> f a -> f b) -> f Int -> f String
mapIntsToStrings fmap = fmap String.fromInt

So you get the added abstraction power without the implicit parameters type classes essentially provide. (And you need these for (your example using) type classes anyway.)

1 Like

This is always a lovely subject, if only because of the insights you get from all the comments.

But honestly, I think the thoughtful language design and its resulting usability are Elm’s greatest assets. Being cautious with the typeclasses is part of that.

Anyway, purescipt is also a beautiful language. It has all those things Elm doesn’t. There is a place for both

2 Likes