Skip to content

Instantly share code, notes, and snippets.

@jimmyfrasche
Created August 24, 2017 18:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jimmyfrasche/ba1d6d32ba87215b29c05fdb54473e7c to your computer and use it in GitHub Desktop.
Save jimmyfrasche/ba1d6d32ba87215b29c05fdb54473e7c to your computer and use it in GitHub Desktop.
Issues with default when simulating sum types

I think exhaustiveness has been a red herring in the sum type discussion.

It's looking at the problem from the wrong direction.

The important issue is not to ensure that every defined case is checked. Rather, it is to ensure that only the defined cases are possible.

Let's consider a non-exhaustive example.

Say we have a sum type, S, that can only take the types A, B, C, D, or E. We want to define a function, F, that does something if the type is A, B, or C and otherwise does nothing at all.

If S is simulated with an interface then F might like this:

func F(s S) error {
  switch v := s.(type) {
  case A:
    useA(v)
  case B:
    useB(v)
  case C:
    useC(v)
  case D, E:
    // do nothing: legal types we don't care about
  default:
    // illegal: nil or a type created by embedding a legal type
    return fmt.Errorf("pkg.F: invalid S: %T", s)
  }
  return nil
}

The subtlety here is what default means.

Without the empty case for D and E, it bins valid and invalid types together.

Adding a new type to S flags a valid type as invalid if F is not updated at the same time as S. This can be difficult to coordinate if S comes from a separate package in a different project.

It is, unfortunately, better to not handle the error. Doing so can cause correct code to become incorrect.

We have to write

func F(s S) error {
  switch v := s.(type) {
  case A: useA(v)
  case B: useB(v)
  case C: useC(v)
  case nil: return errors.New("pkg.F: nil S invalid")
  }
  return nil
}

Say, now, that we want to change F to perform a default action whenever S is nil or a valid type that is not one of A, B, or C.

We cannot use default. To avoid performing the default action when given an invalid type, we have to use case nil, D, E:.

We have to write

func F(s S) {
  switch v := s.(type) {
  case A: useA(v)
  case B: useB(v)
  case C: useC(v)
  case nil, D, E: performDefault()
  }
}

With this change, if a new type is added to S, it does not have the default action performed. We need to include a default case to catch any newly added types so that we can update our code.

We have to write

func F(s S) error {
  switch v := s.(type) {
  case A: useA(v)
  case B: useB(v)
  case C: useC(v)
  case nil, D, E: performDefault()
  default:
    return fmt.Errorf("pkg.F: invalid S: %T", s)
  }
  return nil
}

This is a bad situation. We cannot handle the error. We must handle the error. The double-meaning of default can have subtle implications for the correctness of code over time.

If S were somehow restricted so that it could contain only the valid types then default only means valid types not covered in a different case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment