Just for fun: What if there were no type classes at all?

In Elm Europe 2019, Richard Feldman made a fun talk exploring “What if the only language feature Elm had was custom types?”

Why would you want a language with only custom types? Who cares! It’s fun to explore.

So here’s my what if: “What if Elm had no type classes?”

Note: This is not a proposal. I’m not saying this is a good idea. It’s just fun talking about programming languages!

If you feel like this sounds like an interesting thought experiment – read on!
If you feel like commenting – please do! But please avoid “Why would you do/want/need this?” type of comments – that’s not the point here.

The four type classes of Elm

As far as I know, Elm has four type classes:

  • number
  • comparable
  • appendable
  • compappend

Let’s explore what things would look like if those didn’t exist!

appendable

I think the only affected function would be ++. It currently works with both strings and lists, but would have to be restricted to just one of those.

Idea: Remove ++ altogether! And replace it with:

  • String interpolation: "Total: ${String.fromInt total} USD. Your share: ${String.fromInt yourShare} USD."
  • A variant of List.append that lets you do [1, 2, 3] |> List.append [4, 5, 6] to get [1, 2, 3, 4, 5, 6]. Not sure what a good name would be! Or maybe even allow [1, 2, ...someList] like in JS.

comparable

Here’s what I use comparisons for:

  • To figure out which number is bigger.
  • To sort things.

There’s one more use case: Set and Dict needs to be able to compare its keys.

Idea:

  • Let <, >, <=, >=, compare, min, max, List.maximum, List.minimum only work with numbers. (Notice how I’ve already mentioned “number” twice? We’ll get back to that in the section about the number typeclass!)
  • Introduce something like localeCompare for comparing strings.
  • List.sortBy (\person -> (person.lastName, person.firstName)) wouldn’t work anymore. This can be replaced with new sorting API:s! See below.
  • For data structures such as Set and Dict – I’ll get back to that below.

Sorting

Here are some functions I’ve been toying with:

thenBy : (a -> a -> Order) -> a -> a -> Order -> Order
thenBy f a1 a2 order =
    case order of
        LT ->
            LT

        EQ ->
            f a1 a2

        GT ->
            GT


x =
    List.sortWith
        (\p1 p2 ->
            String.localeCompare p1.lastName p2.lastName
                |> thenBy String.localeCompare p1.firstName p2.firstName
        )


compareMap : (a -> b) -> (b -> b -> Order) -> (a -> a -> Order)
compareMap mapper comparer a1 a2 =
    comparer (mapper a1) (mapper a2)


y =
    List.sortWith
        (\p1 p2 ->
            compareMap .lastName String.localeCompare p1 p2
                |> thenBy (compareMap .firstName String.localeCompare) p1 p2
        )


thenBy2 : (a -> a -> Order) -> (a -> a -> Order) -> (a -> a -> Order)
thenBy2 f1 f2 a1 a2 =
    f1 a1 a2
        |> thenBy f2 a1 a2


z =
    List.sortWith
        (compareMap .lastName String.localeCompare
            |> thenBy2 (compareMap .firstName String.localeCompare)
        )

There’s also elm-sorter-experiment by Richard Feldman.

Set and Dict

Let’s say you have a custom type:

type Status
    = New
    | Open
    | Closed

And now you’d like to make a filters : Set Status. Set needs to be able to order its elements (implementation detail).

Idea: The compiler could just decide that Status values should be ordered in the order they where defined: New, then Open, then Closed.

A downside of that is that if somebody sorted a list of Status, and someone else were in a refactoring mood and felt like ordering all custom types alphabetically:

type Status
    = Closed
    | New
    | Open

Then they would accidentally have changed how that list was sorted. Oops!

So my idea is to have a special function with an annoying name that is only intended to be used for data structures (where the order doesn’t matter, just the fact that things can be ordered, unless I’m mistaken). Something like Basics.orderForUseInDataStructures : a -> a -> Order (but with a better name). Maybe that function shouldn’t return Order to be incompatible with sorting functions (instead, it could return InteralOrder that is otherwise identical to Order).

The idea here is that the compiler could “just know” how to order any values, just like it “just knows” how values are equal (but there’s no equalable type class). And this ordering should only be used for data structures, not sorting a list before displaying it or so.

compappend

compappend is comparable and appendable at the same time. (“Only strings and lists are both comparable and appendable” according to the compiler.)

We’ve already removed comparable and appendable above, so there shouldn’t be a need for this combination anymore.

number

Finally, the tricky one.

Here’s a thread about it: The problems with numbers in Elm

And a GitHub issue which suggests having different operators for Int and Float: https://github.com/elm/compiler/issues/2078

The clever thing about the “different operators” proposal is that it adds a . at the end of operators, just like you add a . in a literal to make it a float. You’d have 5 + 3 and 5.0 +. 3.1. And 5 / 2 == 2 while 5.0 /. 2.0 == 2.5. And 5 > 2 vs 5.0 >. 2.0.

In this universe of “no typeclasses” (for no particular reason), that’s the best I can come up with.

If so, I think it would make sense to separate out most functions from Basics to new Int and Float modules.

(If you want to take everything one step further – what if there was no operators at all (except pipes)? Like, 1 |> Int.add 2 |> Int.equals 3 instead of 1 + 2 == 3. But let’s not go there in this thread.)

11 Likes

Another possibility to get rid of appendable would be to go the Haskell route and define strings as List Char, and then they just are lists, so lists are the only thing you need to be appendable. (In keeping with the spirit of the thread I’m not endorsing this, just stating it as a possibility).

I quite like string interpolation in other languages, though I’ve found in Elm I’ve needed it less, mostly because I can often substitute with multiple Html.text elements, eg.

div
    []
    [ Html.text ("Hello " ++ person.name ++ " how are you?") ]

is fine as:

div
    []
    [ Html.text "Hello "
    , Html.text person.name
    , Html.text " how are you?"

Still string interpolation means I’m less likely to forget the trailing/proceeding spaces required. I sometime use String.join " " for this purpose as well, obviously I have to if the interpolated string is not being output as Html.

5 Likes

To be honest, I would love if Elm went the exact opposite way and allowed for some rudimentary trait implementation.

I would love to be able to implement a toString for a custom type and just use the type where a String is needed. Of course, String in function signatures would have to be replaced by a Stringable trait.

And string interpolation. I just love the way elixir handles string interpolation and is one of the practical things I would love to have in Elm.

Unfortunately I have no idea how difficult these things would be to implement and what impact would it have on compilation times. They probably are a way to big of a tradeoff in terms of compiler complexity and performance.

2 Likes

Interesting!

It feels like we’re going a little bit off-topic here, but I can’t help myself:

At work we have a custom type RejectionReason. It can be turned into strings in 3 ways:

-- Snake case strings for computers (maybe we should rename this one).
toString : RejectionReason -> String

-- For customers:
toCustomerDescription : RejectionReason -> String

-- For internal UIs:
toAgentDescription : RejectionReason -> String

How would that fit into your trait idea?

3 Likes

Of course. You would just have to define a trait that covers this.

trait CustomDescription  for a = 
    { customerDescription : a -> String 
    , agentDescription : a -> String 
    } 

impl CustomDescription for RejectionReason =
   { customerDescription  reason = 
       .... 
   , agentDescription reason = 
      ...
    }

impl String for RejectionReason = 
    { toString reason = ... 
   } 

for usage you would have message reason = i" raw toString ${reason}; for customer ${ reason.customerDescription} ...

This is all pseudocode… but you get the idea.

1 Like

I’ve had similar thoughts before, which resulted in an experimental sorting API that I like!

Numbers can work like this:

(+) : Number a -> Number a -> Number a

type alias Int = Number Integer

type alias Float = Number FloatingPoint

type Integer = Integer
type FloatingPoint = FloatingPoint

In that world, (+) still accepts either Int or Float, and functions that work with specifically Int or speficially Float continue to work as normal.

Fun stuff!

10 Likes

I am not sure if string interpolation is a good thing. Another approach to appendable is doing similar to Elixir having an operator for appending lists (++) and other for string (<>). Or instead of operators, functions.
Edit: List.append and String.append already exists, but arguments are not pipeline friendly. So having 2 operators would be better.

2 Likes

I’ve never really understood why this is the way it is.

From what I’ve learnt with Elm, the ‘main’ data structure that is being mutated should be the last argument, which is not [really] the case with List.append or String.append.

I get that:

String.append "butter" "fly" == "butterfly"
List.append [a,b] [c] == [a,b,c]

and that they read nicely as a result when inlined like that.

But, from my understanding of FP (learned from Elm and Elixir), the general ‘idea’ is to mutate data with pipelines. (Taking Elixir’s Phoenix framework as an example, a request comes in, and that request is mutated into a response through a bunch of pipelines.)

When updating records in Elm, I find that I tend to use individual update functions for each field, simply so that I can pipeline, and if the value of one field relies on the value of another, pipelines allow me to express/handle this quite nicely. I can also introduce additional logic quite easily as a result.

Going back to the examples above taken from the docs, I would consider the ‘main’ data structure that is being mutated to be "butter" and [a,b] due to the fact that they are being appended to, but as they are the first argument, I find that I can’t pipeline these functions and end up writing a separate append function that flips the arguments - which is fine.

In the docs for the append functions, it states that they operate the same way as the (++) operator, why does it have to be this way? Would it be feasible for append and (++) to accept their arguments in opposite orders?

So when inlining is required/preferred you could do:

"butter" ++ "fly"
[a,b] ++ [c]

and when pipelining is required/preferred you do:

"butter"
  |> String.append "fly"

[a,b]
  |> List.append [c]

And then instead of the docs stating “Append two strings. You can also use the (++) operator to do this”, they could say something like:

“Append the first string to the second”

String.append "fly" "butter"
-- append "fly" to "butter"

Sorry if this is a bit off-topic, but it’s a small bug-bear of mine, and as @G4BB3R brought it up, I’ve got time on my hands, and this is an interesting thread… :grinning:

Edit: I think I might have gone against the intent of the original post, if so, apologies, I’m happy to take this down if so. :+1:

3 Likes

List.prepend b a = List.append a b ? :blush:

1 Like

List.appendTo a b = List.append b a?

3 Likes

I’ve been thinking about the problem of ad hoc polymorphism in Elm for about 2 years on and off and my latest thinking is very similar to what you came up with. The one area that I don’t think is sufficiently addressed here is what should be done with the equality operators.

Do we get rid of them entirely? Do we restrict them to “primitive” types only? Do we introduce an equatable “constrained type variable” (I believe this is the official name for Elm’s “typeclasses”) at the same time as we’re eliminating all of the other constrained type variables? Do we let the compiler automatically insert “equatable” constraints on functions that use them without exposing any way to specify this at the source-level? Should developers be able to specify their own functions for performing equality checks or are they still going to be restricted to the compiler-generated ones (for example, it is possible for two binary search trees to contain identical elements but have different internal structures, thereby being conceptually equal but structurally unequal)?

It seems like the best option to stay consistent with this thought experiment would be to get rid of the equality operators entirely. This actually seems reasonable to me but I can see it being polarizing since most languages (at least the ones I am familiar with) have some notion of universal equality operators. I’m curious, what do people think about removing the equality operators too?

Actually I think this is to me the most interesting proposal so far. To me the way equality works in Elm is one of the remaining warts of the language. If there was no polymorphic ==, then this would not be a problem. I also find that I mostly use == for numbers and strings, for custom types I often need some more custom logic (like does this value have this tag, but not caring about the values contained…).

As a whacky idea, perhaps a little syntax sugar on pattern matching could do the trick:

if matches "foo" <- str then 42 else 0
not (matches Just _ <- myVal)
1 Like

There’s also the issue that floating-point equality checks should be performed with respect to some tolerance level. If we get rid of polymorphic equality, people might actually start doing that properly. The OP mentioned that maybe we could start doing something like localeCompare for strings. Bool equality is arguably a code smell in many cases (if b == True then ... should just be if b then ...). That just leaves Int, Char, and the custom types that contain only those things. I think Char equality is uncommon enough that it can be relegated to a Char.eq function, leaving the == operator for just Ints (I think)!

One potential flaw I see is that it could get annoying to have to write the equality functions for custom types by hand. It might be possible to automatically generate equality functions for custom types if all the values it contains have suitable equality functions, but I don’t see an obvious way to do this. (I don’t want to get us too far off-topic, but perhaps the design of such a solution would also allow for generating default JSON encoders/decoders.)

Sorry for bikeshedding on proposed syntax, but as a small alternative to your idea of adding syntax for checking if a value uses a particular constructor, perhaps the compiler could automatically generate predicates for each data constructor. Something like if .Just myVal then ... (similar to how it creates the . getters for each record field). I believe I saw a similar syntax for another functional language recently but I’m forgetting its name.

The trouble with comparing custom types will also be how to automatically compare their payloads. Floats should indeed be compared within some tolerance, so how should any autogenerated equality function behave for

type Custom = Hello Float | World

I don’t use equality of custom types that much myself, except the occasional data == Just something statement. On the other hand, I really, really would like to be able to be able to put custom types in Dicts, so any change should preferably work with that.

Maybe the answer is to make custom functions for that though (which would be fine with me).

Actually to expand on the matches syntax above:

Just in case this wasn’t clear:

matches #PATTERN# <- #VAR#

is just syntax sugar for

case #VAR# of
     #PATTERN# -> 
         True
     _ ->
         False

This is the same built-in notion of equality that exists for case statements - structural equality. It naturally prevents comparing opaque types and functions, which is good.

There would be some question of what to do with variables other than _ in the pattern. Binding them in this case would be meaningless. One option would be to use them to match:

a =
     3

b =
    4

foo = 
      matches b <- a + 1 --> True

This would effectively subsume the current == operator. However, this would then (at least syntactically) allow:

a x =
    x

b x =
    x

foo =
     matches a <- b --> Runtime Error?

Which is back to square one.

The other option would be to simply disallow them.

1 Like

Would it be enough if using (==) with floats or functions were compilation errors?

Also, should comparing floats require a tolerance as well? For example, do you expect 0.1 + 0.2 > 0.3 to be true or false?

1 Like

Going back to the original questions of removing all type classes:

As far as I can tell, the only reason for type classes is to mitigate the need to pass both a type and required functions explictely. A type class implicitely associates functions to a type, so that passing the type also passes the functions.

You can always replace a type class with a record of the required functions. For example any function that takes comparable could also take a comparing function:

-- Instead of
sort : List comparable -> List comparable

-- ask for
sort : (a -> Order) -> List a -> List a

The type class might define multiple functions, such as number, but you can always pass in multiple functions. You could even define a record that defines the type class:

type alias Number a =
  { compare : a -> a -> Order
  , add : a -> a -> a
  , multiply : a -> a -> a
  -- and so on
  }

-- offer implementations for common data types (let's imagine the implementation is defined in a module Int)
intNumber : Number Int
intNumber =
  { compare = Int.compare
  , add = Int.add
  , multiply = Int.multiply
  ...
  }

Interestingly, if your functions only needs the type to support add, you could use an extensible record such as in sum : { n | add : a -> a -> a-> } -> List a -> a, so that you could simply sum intNumber [ 1, 2, 3 ], which is suprisingly ergonomic.

The reason we have type classes is only for convenience. It’s not too often that we need to implement those functions ourselves and thus it’s easier to type

sort [ 2, 3, 1 ]

then the (already totally possible) alternative

sort intNumber [ 2, 3, 1 ]

Maybe there are also implementation details that make it more performant to have number as a type class. But even if we got rid of them, I could imagine the compiler doing an extra pass to detect optimizable numbers, so that we could at least theoretically have the same performance.

To summarize, I think the only thing that type classes offer is syntax sugar for implictely passing certain functions with a type. You can always pass those functions explicitely.

3 Likes

This actually already exists, see nikita-volkov/typeclasses and Typeclasses.Extensions.List.sort (and hashing-containers). So it seems already possible to experiment without using constrained type variables.

It could be interesting to replace from elm-spa-example any function call that use a constrained type variable to see the actual impact.

2 Likes

One extra parameter more is not that horrible ergonomics for ordinay functions, but the issue I see mainly with operators. How do you deal with +, -, *, etc. syntactically?

Also: How do you deal with literals? Currently, when the compiler sees “0” it simply infers the type “number” which may be unified with “Int” or “Float” later. In haskell, a literal “0” becomes “Num a => a”. I could see just requiring to be more explicit here a valid trade-off. 0 and 0f are already established convention.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.