Search code examples
typescriptmethodstype-inferencecontravariancephantom-types

How to make phantom types work with methods in TypeScript?


Consider the following program using phantom types:

const strlen = (str: string) => str.length;

type Const<A, B> = { type: 'Const', value: A };

const Const = <A, B = never>(value: A): Const<A, B> => ({ type: 'Const', value });

const map = <A, B, C>(_f: (value: B) => C, { value }: Const<A, B>): Const<A, C> => Const(value);

const contramap = <A, B, C>(_f: (value: C) => B, { value }: Const<A, B>): Const<A, C> => Const(value);

const constant = Const(true);

map(strlen, constant); // works

contramap(strlen, constant); // works

Playground

The above program type checks because the correct types are inferred. The constant value has the inferred type Const<boolean, never>. The map function is called with the types A = boolean, B = string, and C = number. The contramap function is called with the types A = boolean, B = number, and C = string.

However, it would be nice to write the above expressions using methods instead of functions. Hence, I tried the following:

const strlen = (str: string) => str.length;

interface Const<A, B> {
    map: <C>(f: (value: B) => C) => Const<A, C>;
    contramap: <C>(f: (value: C) => B) => Const<A, C>;
}

const Const = <A, B = never>(value: A): Const<A, B> => ({
    map: () => Const(value),
    contramap: () => Const(value)
});

const constant = Const(true);

constant.map(strlen); // works

constant.contramap(strlen); // error

Playground

As you can see, the map method works but the contramap method doesn't. This is because the type of constant is Const<boolean, never> and it's not refined by the method call, i.e for map the type is not refined to Const<boolean, string> and for contramap the type is not refined to Const<boolean, number>.

Because of this, either map or contramap work but not both. If the type of the object is Const<boolean, never> then contramap doesn't work. If the type of the object is Const<boolean, unknown> then map doesn't work.

How can I make both map and contramap work using methods instead of functions?


Solution

  • I solved this problem by making the type parameter B, of the Const interface, a phantom type.

    const strlen = (str: string) => str.length;
    
    interface Const<A, B> {
        map: <B, C>(f: (value: B) => C) => Const<A, C>;
        contramap: <B, C>(f: (value: C) => B) => Const<A, C>;
    }
    
    const Const = <A, B = never>(value: A): Const<A, B> => ({
        map: () => Const(value),
        contramap: () => Const(value)
    });
    
    const constant = Const(true);
    
    constant.map(strlen); // works
    
    constant.contramap(strlen); // works
    

    Playground

    The type parameter B, of the Const interface, is now shadowed by the type parameters of map and contramap. This makes sense because the type parameter B, of the Const interface, is a phantom type. Hence, it shouldn't be used. On the other hand, the callers of map and contramap should be able to decide what type the type parameter B should be instantiated with.