Does the open/closed principle apply to Elm?

Does the open/closed principle apply to Elm?

Rob Looby
Rob Looby

August 08, 2017

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 Shapes. 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 Shapes 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.