This content has moved to pzel.name

Uses for traits vs type aliases in Ponylang

Today I learned (2019-04-06)
Tagged: pony ponylang

I realized today that while both traits and type aliases can be used to represent a union of types in Pony, each of these solutions has some characteristics which make sense in different circumstances.

Traits: Keeping an interface open

Let's say you have the trait UUIDable.

trait UUIDable
  fun uuid(): UUID

and you have a method that accepts objects implementing said trait.

  fun register_uuid(thing: UUIDable): RegistrationResult =>

Delcaring the function parameter type like this means that any thing that has been declared to implement the trait UUIDable will be a valid argument. Inside the method body, we can only call .uuid() on the thing, because that's the only method specified by the trait.

We can take an instance of the class class User is UUIDable, and pass it to register_uuid. When we continue development and add class Invoice is UUIDable, no change in any code is required for register_uuid to also accept this new class. In fact, we are free to add as many UUIDable classes to our codebase, and they'll all work without any changes to register_uuid.

This approach is great when we just want to define a typed contract for our methods. However, it does not work when we want to explicitly break encapsulation and – for example – match on the type of the thing.

fun register_uuid(thing: UUIDable): RegistrationResult =>
  match thing
  | let u: User => _register_user(u)
  | let i: Invoice => _register_invoice(i)
  end

The compiler will complain about this method definition, because it can't know that User and Invoice are the only exising types that satisfy UUIDable. For the compiler, this now means that any UUIDable thing that is not a User or an Invoice will fall through the match, and so the resulting output type must also include None, which represents the 'missed' case in our match statment.

We know that the above match is indeed exhaustive. Users and Invoices will be the only types that satisfy UUIDable. How can we let the compiler know?

Type aliases: explicit and complete enumerations

If we want to break encapsulation, and are interested in an exhaustive and explicit union type, then a type alias gives the compiler enough info to determine that the match statement is in fact exhaustive:

type UUIDable is (User | Invoice)
fun register_uuid(thing: UUIDable): RegistrationResult =>
  match thing
  | let u: User => _register_user(u)
  | let i: Invoice => _register_invoice(i)
  end

Different situations will call for different approaches. The type alias approach means that anytime you add a new UUIDable, you'll have to redefine this alias, and have to go through all your match statements and add a new case. The silver lining is that the compiler will tell you which places you need to modify.

Also, note that you can still call thing.uuid() and have it type-check, as the compiler can determine that all classes belonging to (User | Invoice) actually provide this method.

Encapsulation vs. exhaustiveness

Using traits (or interfaces for even more 'looseness') means that, in the large, your code will have to conform to the OOO practices of loose coupling, information hiding, and encapsulation.

Using union types defined as type aliases means that encapsulation is no longer possible, but the compiler will guide you in making sure that matches are exhaustive when you take apart function arguments. This results in the code looking more 'functional' in the large.

You can play around with this code in the Pony playground.