Yet another SWE Blog.

Variants are incredibly powerful

Nigel Schuster
Nigel Schuster

Variants allow you to express that a value may be in more than one distinct states. Over the last years I have grown more fond of leveraging variants throughout my code to express the state that my program is in. Most programming languages support some form of variants, but are sometimes not given the time of day. For me personally, I often caught myself neglecting the usage of variants due to the verbosity of introducing additional types.

A trivial example to start with is that we want to calculate the surface area of a geometric shape. In Typescript with an interface we could write something along the lines of:

interface GeometricShape {
  getSurfaceArea(): number;
}
class Square implements GeometricShape {
  length: number;
  constructor(length: number) {
    this.length = length;
  }
  getSurfaceArea(): number {
    return this.length * this.length;
  }
}
class RightTriangle implements GeometricShape {
  a: number;
  b: number;
  constructor(a: number, b: number) {
    this.a = a;
    this.b = b;
  }
  getSurfaceArea(): number {
    return (this.a * this.b) / 2;
  }
}

However, Typescript also supports polymorphism via variants as discriminated unions:

type square = {
  type: "square";
  length: number;
};
type rightTriangle = {
  type: "rightTriangle";
  a: number;
  b: number;
};
type geometricShape = square | rightTriangle;
function surfaceArea(shape: geometricShape): number {
  switch (shape.type) {
    case "square":
      return shape.length * shape.length;
    case "rightTriangle":
      return (shape.a * shape.b) / 2;
  }
}

If you take a second to think about the two ways to implement a function to compute the surface area, you will realize that users may interact very differently with the two APIs. In fact, I would consider the interface solution more powerful in this example: The user of the API is able to add more shapes, as long as it implements GeometricShape. However, the challenge lies in designing a good API that can be implemented by all classes.

To continue our example, let's consider that we added a new function getAnglesOfShape(): Array<number> that returns the angles of the shape. We can then use it to validate that the inner angles add up to 360.

interface GeometricShape {
  getInteriorAngles(): Array<number>;
}
function validateShape(shape: GeometricShape) {
  const angles = shape.getInteriorAngles();
  let exteriorAngleSum = 0;
  for (var angle of angles) {
    exteriorAngleSum += 180 - angle;
  }
  return exteriorAngleSum == 360;
}

Unfortunately, now a user attempted to implement a Circle, but struggles to implement getAnglesOfShape. It seemed like a perfectly reasonable method to add at the time, but now we found a shape that has infinitely many angles (or none?) and can't return a sensible result that can pass our validation. While a variant by itself can't save us, it simplifies our validationCode:

function getInteriorAngles(shape: square | rightTriangle): Array<number> {
  switch (shape.type) {
    case "square":
      return [90, 90, 90, 90];
    case "rightTriangle":
      const alpha = Math.atan(shape.a / shape.b);
      return [alpha, 90 - alpha, 90];
  }
}
function validateShape(shape: geometricShape) {
  switch (shape.type) {
    case "circle":
      return true;
    default:
      const angles = getInteriorAngles(shape);
      let exteriorAngleSum = 0;
      for (var angle of angles) {
        exteriorAngleSum += 180 - angle;
      }
      return exteriorAngleSum == 360;
  }
}

While we partially simply profit from restricting the user from implementing types, we can also note that we pushed the responsibility of handling the new shape to the consumer.

I hope this gave you a little taste of why variants may be useful. One place where variants really start to shine is when writing finite state machines. For example, let's try to model a connection that automatically reconnects if it got disconnected. It's easy to express with a variant:

interface Request {}
interface Respoonse {}
interface Connection {
  sendRequest(request: Request):
    | {
        status: "sent";
        result: Promise<Response>;
      }
    | { status: "notSent" };
}
interface ConnectionManager {
  makeConnection(): Promise<Connection>;
}
type connectionStatus =
  | {
      status: "disconnected";
    }
  | {
      status: "reconnecting";
      pending: Promise<Connection>;
    }
  | {
      status: "connected";
      connection: Connection;
    };
class RetryingConnection {
  currentConnection: connectionStatus;
  manager: ConnectionManager;
  constructor(manager: ConnectionManager) {
    this.manager = manager;
    this.currentConnection = {
      status: "disconnected",
    };
  }
  async sendRequest(request: Request): Promise<Response> {
    let sentRequest;
    do {
      switch (this.currentConnection.status) {
        case "connected":
          sentRequest = this.currentConnection.connection.sendRequest(request);
          break;
        case "disconnected":
          const pendingConnection = this.manager.makeConnection();
          this.currentConnection = {
            status: "reconnecting",
            pending: pendingConnection,
          };
        case "reconnecting":
          sentRequest = await this.currentConnection.pending.then(
            (connection) => connection.sendRequest(request)
          );
      }
      if (sentRequest.status == "notSent") {
        this.currentConnection = {
          status: "disconnected",
        };
      }
    } while (sentRequest.status == "notSent");
    return sentRequest.result;
  }
}

Modeling the transitions between "disconnected", "reconnecting", and "connected" is simply not possible without variants.

Published on 2021-11-24.


More Stories