Design – Sum Types vs Polymorphism

designfunctional programmingobject-oriented

This past year I took the leap and learned a functional programming language (F#) and one of the more interesting things that I've found is how it affects the way I design OO software. The two things I find myself missing most in OO languages are pattern matching and sum types. Everywhere I look I see situations that would be trivially modeled with a discriminated union, but I am reluctant to crowbar in some OO DU implementation that feels unnatural to the paradigm.

This generally leads me to create intermediate types to handle the or relationships that a sum type would handle for me. It also seems to lead to a good deal of branching. If I read people like Misko Hevery, he suggests that good OO design can minimize branching through polymorphism.

One of the things I avoid as much as possible in OO code is types with null values. Obviously the or relationship can be modeled by a type with one null value and one non-null value, but this means null tests everywhere. Is there a way to model heterogeneous but logically associated types polymorphically? Design strategies or patterns would be very helpful, or simply ways to think about heterogeneous and associated types generally in the OO paradigm.

Best Answer

Like you, I wish that discriminated unions were more prevalent; however, the reason they are useful in most functional languages is that they provide exhaustive pattern matching, and without this, they are just pretty syntax: not just pattern matching: exhaustive pattern matching, so that the code doesn't compile if you don't cover every possibility: this is what gives you power.

The only way to do anything useful with a sum type is to decompose it, and branch depending on which type it is (e.g. by pattern matching). The great thing about interfaces is that you don't care what type something is, because you know you can treat it like an iface: no unique logic needed for each type: no branching.

This isn't a "functional code has more branching, OO code has less", this is a "'functional languages' are better suited to domains where you have unions - which mandate branching - and 'OO languages' are better suited to code where you can expose common behaviour as a common interface - which might feel like it does less branching". The branching is a function of your design and the domain. Quite simply, if your "heterogeneous but logically associated types" can't expose a common interface, then you have to branch/pattern-match over them. This is a domain/design problem.

What Misko may be referring to is the general idea that if you can expose your types as a common interface, then using OO features (interfaces/polymorphism) will make your life better by putting type-specific behaviour in the type rather than in the consuming code.

It is important to recognise that interfaces and unions are kind of the opposite of each other: an interface defines some stuff the type has to implement, and the union defines some stuff the consumer has to consider. If you add a method to an interface, you have changed that contract, and now every type that previously implemented it needs to be updated. If you add a new type to a union, you have changed that contract, and now every exhaustive pattern matching over the union has to be updated. They fill different roles, and while it may sometimes be possible to implement a system 'either way', which you go with is a design decision: neither is inherently better.

One benefit of going with interfaces/polymorphism is that the consuming code is more extensible: you can pass in a type that wasn't defined at design time, so long as it exposes the agreed interface. On the flip side, with a static union, you can exploit behaviours that weren't considered at design time by writing new exhaustive pattern-matchings, so long as they stick to the contract of the union.


Regarding the 'Null Object Pattern': this isn't not a silver bullet, and does not replace null checks. All it does it provide a way to avoid some 'null' checks where the 'null' behaviour can be exposed behind a common interface. If you can't expose the 'null' behaviour behind the type's interface, then you will be thinking "I really wish I could exhaustively pattern match this" and will end up performing a 'branching' check.