Debugging tricky parameterized types

Parameterized functions are fantastic. I'm talking about the functions that operate around some abstract piece of data, without doing anything that would require specific knowledge about that data. Take, for example, the following fictional function:

Example

let maybeSaveThing = (maybeThing: option<debug>): option<debug> => {
  switch maybeThing {
  | Some(thing) => saveThing(thing)
  | None => None
  }
}

The code above is designed to take an option of something, save it somewhere if the option contains data, and return the result. We assume, for this example, that the saveThing function knows now to save anything. We can tell that the function shouldn't care what's contained in the option type by looking at the function signature. It takes an option<'a>, and returns an option<'a>.

A Sneaky Bug

But this code has a sneaky bug. We can see it if we look at the function's inferred type:

option<int> => option<int>

The compiler is telling us that the function takes an option of an integer, even though we explicitly stated in the type signature that the function should be generic. Why is that?

Why Don't we Get a Type Error?

Generics encapsulate all other types, that's why. For example, an option<int> is an option<'a> as far as the type system is concerned. So is an option<string> or a option<blah>. If a part of the code inside of a function returns a specific value where a generic value was expected, the type inference algorithm quietly turns the function's parameter into a specific type. The compiler won't tell you that your type annotation is wrong, because it isn't. An option<int> => option<int> is an option<'a> => option<'a>.

Why Are We Getting an int?

Spoiler alert, there's a call inside of our function that returns an int where the generic type was expected. Stick around and we'll see how to find it in a moment.

Discovering the Mismatch

We intended the type to be generic. It's troublesome that we don't discover this breakage until we want to use this function to save some type other than an int. The compiler will tell us that maybeSaveThing takes an option<int> and we're trying to pass in something like option<string>.

How can we discover these unintentional losses of genericism closer to the site of definition?

Enforcing Genericism

Adding an interface (.resi) file with the generic signature for the function will help the compiler out. Instead of inferring the type of the function to be specific, the compiler will know that you intend to keep that function generic, and it'll tell you that your implementation doesn't match the interface.

Finding the Mistake

Unfortunately, an error that the function definitions don't match doesn't go far to help us find the location of the mistake. When we're dealing with more than mere example code, and we have a complex function with lots of operations, it's tempting to abort the whole effort and scroll through Twitter instead.

But here's a trick that can speed the process up:

type debug

We've added a new type to our code that is the opposite of generic. It's so specific that no value fits it. And it's explicitly for debugging, so we know that no other area of the code will rely on this type for business logic.

If we swap out the generic argument in our function definition with debug temporarily:

type debug

let maybeSaveThing = (maybeThing: option<debug>): option<debug> => {
  switch maybeThing {
  | Some(thing) => saveThing(thing)
  | None => None
  }
}

It'll show us the line of code that is returning a specific, non-generic value:

  8 │ let maybeSaveThing = (maybeThing: option<debug>): option<debug> => {
   9 │   switch maybeThing {
  10 │   | Some(thing) => saveThing(thing)
  11 │   | None => None
  12 │   }

  This has type: option<int>
  Somewhere wanted: option<debug>

  The incompatible parts:
    int vs debug

Aha! The call to saveThing is returning a option<int>. If that had been a generic function, its return type would have been option<debug>. Let's look at its definition:

let saveThing = _thing => {
  Some(21)
}

There's the source of the problem. If we modify it to do something generic instead:

let saveThing = thing => {
  let _ = Js.Json.stringifyAny //-> blah blah do some saving in the background
  Some(thing)
}

Then the compiler is happy, and we know our code is generic again. We can remove debug and put the type parameters back in place.

 

Murphy

 

Leave a Reply

Your email address will not be published. Required fields are marked *