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.