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
- Represents 1 out of N variants.
- Implementation is dependent on the language.
- Some support it natively (Scala, F#, Rust, Kotlin, ...)
- Some need compiler extensions, for example, C#
https://github.com/WalkerCodeRanger/ExhaustiveMatching
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
- Scala
- Refined - refinement types for Scala
https://github.com/fthomas/refined
- Shapeless - generic programming for Scala
https://github.com/milessabin/shapeless
- Refined - refinement types for Scala
- C#
- ExhaustiveMatcher - make your
switch
statements exhaustive
https://github.com/WalkerCodeRanger/ExhaustiveMatching
- Extended C# compiler - macros, records, implicits
https://github.com/FPCSharpUnity/Unity3D.IncrementalCompiler
- ExhaustiveMatcher - make your
Q & A
Type systems for safer, clearer code and less tests
By Artūras Šlajus
Type systems for safer, clearer code and less tests
- 552