A discussion came up the other day about if and how the Open/Closed Principle (OCP) was applicable to Elm. The OCP can be stated as "software entities should be open for extension, but closed for modification." In a classic object-oriented programming example, a Shape
type is defined as well as a function that will draw a list of Shape
s. In Java, this might look something like:
/* Shape.java */
interface Shape {
void draw();
}
/* Circle.java */
class Circle implements Shape {
// fields and constructors
@Override
public void draw() {
// draw the circle
}
}
/* Square.java */
class Square implements Shape {
// fields and constructors
@Override
public void draw() {
// draw the square
}
}
/* Main.java */
public static void drawAllShapes(List<Shape> shapes) {
for(Shape shape : shapes) {
shape.draw();
}
}
The solution above follows the OCP because we can extend the behavior of drawAllShapes
without modifying it. We can add a new class implementing the Shape
interface and drawAllShapes
will be able to draw it without any changes.
What does this have to do with Elm? One major feature of Elm (and all ML family languages) is the presence of union types. They're used heavily to represent everything from basic types like Maybe
, Result
, and List
all the way up to complex CSS rules. To write a function that uses a value of a union type, we need to destructure the value by matching on the type constructors and handling each one. The shape example from above translated to Elm might look like:
{- Shape.elm -}
module Shape exposing (..)
import Svg exposing (Svg)
type alias Point =
{ x : Int
, y : Int
}
type Shape
= Circle Point Int
| Square Point Int
{- Main.elm -}
module Main exposing (..)
import Shape exposing (Shape(..))
drawAllShapes : List Shape -> List (Svg msg)
drawAllShapes shapes =
List.map
(\shape ->
case shape of
Circle center radius ->
-- draw the circle
Square center side ->
-- draw the square
)
shapes
It should be obvious that this implementation of drawAllShapes
does not follow the OCP. If we wish to extend the behavior of drawAllShapes
by adding another type of Shape
, we will need to add another branch to the case statement. This might lead to the conclusion that union types necessarily lead to code that violates the OCP. After all, won't we need to go back and update every function that handles Shape
s every time we modify the type?
Let's take another try and see if we can make drawAllShapes
follow the OCP in the Elm example. The easiest thing we can do is extract the function that destructures the Shape
type out of drawAllShapes
.
{- Shape.elm -}
module Shape exposing (Shape, drawShape, makeCircle, makeSquare)
import Svg exposing (Svg)
type alias Point =
{ x : Int
, y : Int
}
type Shape
= Circle Point Int
| Square Point Int
makeCircle : Int -> Int -> Int -> Shape
makeCircle centerX centerY radius =
Circle { x = centerX, y = centerY } radius
makeSquare : Int -> Int -> Int -> Shape
makeSquare centerX centerY side =
Square { x = centerX, y = centerY } side
drawShape : Shape -> Svg msg
drawShape shape =
case shape of
Circle center radius ->
-- draw the circle
Square center side ->
-- draw the square
{- Main.elm -}
module Main exposing (..)
import Shape exposing (Shape, drawShape)
drawAllShapes : List Shape -> List (Svg msg)
drawAllShapes shapes =
List.map drawShape shapes
-- See the complete example: https://ellie-app.com/3XVWdBp4vNka1/0
We can see that drawAllShapes
now follows the OCP. We can extend the behavior of drawAllShapes
without modifying it by adding a new type constructor to Shape
. We'll only need to update the functions within the Shape
module that destructure it. This effect of isolating changes to a single entity is a hallmark of the OCP. When this pattern of "hiding" the type constructors is applied in our project code, it has the same effect that diligently applying the OCP has in our object-oriented codebase.
However, when this technique is applied to package code, the advantages extend beyond those seen in the object-oriented example. Because the Shape
module no longer exports the type constructors for Shape
, it is not even possible to write a function outside the Shape
module that destructures the Shape
type. Doing this forces clients of the Shape
module to be open/closed with respect to the Shape
type. This strategy is so essential to good API design in Elm that it is included in the Elm package design guidelines.
So does the open/closed principle apply to Elm? Absolutely. And if you liked the benefits it gave to your object-oriented code, you're going to love the guarantees it gives you in Elm.