Type systems for safer, clearer code and less tests

Artūras Šlajus
<x11@arturaz.net>

2022

Context

  • This is most useful when the cost of error is high.
  • If you can fix and redeploy things in a couple of minutes, you will probably think this is overcomplicated.
  • However, if you are at mercy of Apple/Microsoft/Sony with their 2-week review cycle, you might find this useful.
  • This is also useful when your projects are large and with a large surface area.
  • My daily job is making computer games.

So, you like dynamic typing?

  • I did as well... once.
  • Dynamic typing comes at a cost:
    • Shitty refactoring experience.
    • Find usages? LOL.
    • Type errors are almost inevitable despite tests.
    • There are no types, thus meaning has to be communicated via comments on function args. Please repeat yourself!
  • Over time I migrated from dynamic typing (PHP, Python, Ruby) toward static typing, preferably with advanced type systems (Scala, Rust, and to an extent C#, Kotlin, ...Java?; and no, Go barely counts as static typing).

How does static typing help us?

It reduces the number of tests

  • Tests kind of suck - they need to be written and maintained.
  • The best test is one that does not exist.
  • If you can make illegal states not representable - you don't need to write tests for them!

It increases code clarity

  • Understandable code communicates not only what is being done but also why is it being done.
  • By encoding specific business domain concerns as separate types you can:
    • Write comments once for the type instead of at each usage site.
    • Make sure that certain invariants are being held true in the whole codebase.

It reduces the amount of runtime exceptions

  • Runtime exceptions occur when the data or state is not what we expect it to be.
  • We can eliminate that with static typing by making illegal states not representable (by preventing compilation of the code).

It makes developers (and business people) happier

  • Sure, we spend a bit more time writing the code.
  • But...
    • When you inevitably need to understand or modify the code 6 months later, you'll have an easier time doing it with lesser probability for errors.
    • Total time for producing and then maintaining the functionality goes down, which brings down the cost and improves development velocity, thus - it makes business people happier.

So how do we actually do it?

Semantic types

// Bad
case class Person(
  firstName: String,
  lastName: String
)

val firstName = "John"
val lastName = "Smith"
val person = Person(lastName, firstName)

Scala

Semantic types

// Good
case class FirstName(name: String)
case class LastName(name: String)
case class Person(
  firstName: FirstName,
  lastName: LastName
)
val firstName = FirstName("John")
val lastName = LastName("Smith")

// Compiler error!
val person = Person(lastName, firstName)

Scala

Semantic types

  • Benefits of semantic types:
    • We can attach semantic meaning to the raw types like string or int.
    • We can document that meaning once on the type itself, not in every place where the argument is used.
    • The compiler can tell us if we mixed up arguments.
    • We can restrict the valid values using runtime or macro-based smart constructors.
    • We can define a valid set of operations between types, like Volts * Amperes = Watts

Semantic types - smart constructors

case class Email private (email: String)
object Email {
  // Smart constructor
  def create(s: String): Either[String, Email] = {
    if (/* validation passes */) Right(new Email(s))
    else Left("error message")
  }
}

Scala

Semantic types - operations

case class Volts(v: Double) {
  def *(a: Amperes): Watts = Watts(v * a.v)
}

case class Amperes(v: Double) {
  def *(v: Volts): Watts = v * this
}

case class Watts(v: Double)

// Fun fact - NASA blew up a rocket because they
// multiplied miles with kilometers.

Scala

Union (sum) types

 

Union (sum) types

[Closed(new[] {
  typeof(Mode.Standard), typeof(Mode.KillBased)
})]
public interface IMode {}
public static class Mode {
  public record Standard(t: MatchTime) : IMode {}
  public record KillBased(k: Kills) : IMode {}
}

public bool hasFinished(s: GameState, m: IMode) =>
  m switch {
    Mode.Standard s => /* logic */,
    Mode.KillBased k => /* logic */,
    _ => ExhaustiveMatch.Failed(m)
  };

C#

Proof values

  • Encodes order between side effects.
struct MovementHandledProof {}

MovementHandledProof handleMovement() {
  // ...
  return new MovementHandled();
}
void handleShooting(MovementHandledProof p) {}

public void mainLogic() {
  var proof = handleMovement();
  handleShooting(proof);
}

C#

Path dependent types

  • Needs to be supported by your language.
  • Binds certain values to specific instances.
  • Most useful with immutable data structures.

 

Path dependent types

trait SafeVector[A] {
  case class Index(i: Int)

  def indexBy(f: A => Boolean): Option[Index]
  def get(i: Int): Option[A]
  def get(i: Index): A
}

Scala

Path dependent types

def doStuff(
  v1: SafeVector[String],
  v2: SafeVector[String]
): Unit = {
  val maybeIdx1 = v1.indexBy { str => 
    str.length == 5
  }
  maybeIdx1.foreach { idx1 =>
    // Type error:
    //   expected: value of type v2.Index
    //   found:    value of type v1.Index
    v2.get(idx1)
  }
}

Scala

Constrained types

  • Similar to semantic types, but provides knowledge about the structure of the data, not the semantic meaning.
  • Things like NonEmpty[A], Even[A], and other predicates.
  • When we know the structure, we can say that certain operations are 100% safe, like getting first or last element of a non-empty collection.
  • If implemented using generics can get complex, non-compatible types. These two are logically equivalent, but not to a compiler:
    • NonEmpty[Even[Vector[String]]
    • Even[NonEmpty[Vector[String]]
  • Other encodings exist if the language supports, for example using type tags and union types:
    • Vector[String] with NonEmpty with Even

Proper type signatures

  • A lot of information can be communicated to both humans and the compiler by using type signatures. For example:
// Potentially failing.
def splitInHalf[A](
  v: Vector[A]
): Either[String, (Vector[A], Vector[A])]

// Will always succeed.
def splitInHalf[A](
  v: Vector[A] with Even
): (Vector[A], Vector[A])

Scala

Conclusions

  • Which techniques from these you can use depends on your language.
  • It pays to know your language capabilities well.
  • While writing this takes more time, from my experience it pays for itself by:
    • Removing certain classes of tests.
    • Making it easier for developers to understand and reason about the code.
    • Making it less error-prone to move code around and refactor it, because the compiler has your back.

Tools you might enjoy

Q & A

Type systems for safer, clearer code and less tests

By Artūras Šlajus

Type systems for safer, clearer code and less tests

  • 574