Uses for traits vs type aliases in Ponylang
Today I learned (2019-04-06)
Tagged:
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. User
s and Invoice
s 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.