Search code examples
typescriptfunctional-programmingoption-typefunctor

Why typescript cannot refer the type in my example correctly?


Considering the code for Option ADT as below (pretty similar to fp-ts Option):

export type Option<A> = Some<A> | None

export interface Some<A> {
  _tag: 'Some'
  value: A
}

export interface None {
  _tag: 'None'
}

export const some = <A>(x: A): Option<A> => 
  ({ _tag: 'Some', value: x })

export const none: Option<never> = 
  { _tag: 'None' }

export const isNone = <A>(x: Option<A>): x is None => 
  x._tag === 'None'

type Match = <A, B>(onNone: () => B, onSome: (a: A) => B)
  => (x: Option<A>) => B
  
const match: Match = (onNone, onSome) => x =>
  isNone(x) ? onNone() : onSome(x.value)

I'm trying to write the Option map Functor mapOption, but it cannot refer the types correctly. You can also see the error in this typescript playground.

type MapOption = <A, B>(f: (x: A) => B) => (fa: Option<A>) => Option<B>
const mapOption: MapOption = f => fa => match(
  () => none,
  (value) => f(value))(fa)

For some reason it is inferring the types incorrectly. How can I fix the typing error?


Solution

  • One problem with your code was that it was not correct, so really there's no hope at all of the compiler inferring the types properly. The proper implementation looks like this:

    const mapOption: MapOption = f => fa => (
      match(
        () => none,
        value => some(f(value)) // error!
        // -----------> ~~~~~
        // Argument of type 'unknown' is not assignable to parameter of type 'A'.
      )
    )(fa);
    

    But, as you can see, the inference issue remains. The compiler fails to infer the type of value from context, and thus it falls back to the unknown type.

    This sort of thing is ultimately a design limitation of TypeScript. The language can't always infer generic type arguments and simultaneously infer types from context. The currently open issue about the general problem is microsoft/TypeScript#47599.

    While there have been improvements here over the years, it's probably always going to be there in some form. TypeScript does not use a full unificiation algorithm for type inference, as in some other functional programming languages, and as requested in microsoft/TypeScript#30134.

    And it probably never will use such an algorithm, since (according to this comment on microsoft/TypeScript#17520), the current inference algorithm "has the distinct advantage of being able to make partial inferences in incomplete code which is hugely beneficial to statement completion in IDEs."


    We have to work around this if you want your code to compile. One way is to do the thing you don't want to do: "inline" the types. Maybe like this:

    const mapOption: MapOption = f => fa => (
      match(
        () => none,
        (value: Parameters<typeof f>[0]) => some(f(value))
      )
    )(fa);
    

    Here I'm using the Parameters utility type to get a handle on the anonymous generic type parameter corresponding to A. Otherwise you'd have to do it very explicitly and actually declare the type parameters:

    const mapOption3: MapOption = <A, B>(f: (x: A) => B) => (fa: Option<A>) => (
      match(
        () => none,
        (value: A) => some(f(value))
      )
    )(fa);
    

    Another workaround is to make use of some point-free style to eliminate fa and thus make type inference easier by shortening the "distance" between the expected return type and the required input type:

    const mapOption: MapOption = f => match(
      () => none,
      value => some(f(value))
    );
    

    This way is probably ideal for the particular code example, but you might need to resort to one of the earlier approaches in general. Without full unification, you'll have to specify types in some places where they might have otherwise been inferred.

    Playground link to code