Typing Optics (4): Getters and Const

This is the 4th post documenting my tentative to add typings to my focused lens library.

So far, I have type definitions for

Next I’ll be adding typing for accessor functions view, preview, … this requires typing Getters.

Gettings/Getters

For context, focused defines four accessor functions.

In Haskell, all the above functions take a Getting as first parameter. The (simplified) definition is

type Getting r s a = (a -> Const r a) -> s -> Const r s

Again lens over tea explains in detail the motivation behind the above representation.

In this post I’ll be ..ahem.. focusing on the TypeScript implementation. For a short explanation, observe that the above definition is just a specialization of the other Optic definitions (for example replace Getting r with Lens and Const r with some arbitrary Functor f to obtain the Lens definition). We’re specializing the definition to Const mainly to avoid updating a read only Optic.

So we need, somehow, to define the Const Functor (and Applicative) as well as Getting. And the representation has to be consistent with other Optics for the composition to still work and the compiler to infer the right types.

In Haskell, Const is defined as a compile-time wrapper

newtype Const r a = Const { getConst :: r }

In other words, Const holds a value of type r but from the perspective of the type system it is both an r and an a. In TypeScript we could achieve a similar thing by using an intersection type:

type Const<R, A> = R & A;

Of course, the real value is R. A is just a phantom type.

Now for Getting, the trick (or the hack) is to give Getting a shape similar to other optics, but with additional constraints on the mapped types

interface Getting<R, S, A> {
  readonly $type?: "Getting";
  $applyOptic: <FA extends Const<R, A>, FS extends Const<R, S>>(
    F: Applictive<A, S, FA, FS>,
    f: Fn<A, FA>,
    s: S
  ) => FS;
}

I beleive the ... extends Const<R, X> clauses are not effective with TypeScript bivariance on function parameters. But the $type?: Getting ensures that we don’t actually set or update Getters (which can be created using the to function). This requires of course that we add Getting to the type of all other optics which is in fact true (they are all Getters).

interface Iso<S, T, A, B> {
  readonly $type?: "Getting" & "Iso" & "Lens" & "Traversal";
  // ...
}
// ... idem for all other optics

We need also to add an overload to compose, because composing a Getter with another Optic should always result on a Getter. Since Getting is now the most basic type (instead of Traversal) we need to add the overload at the bottom after all the others

// ... all other overloads
function compose<S, T, A, B, X, Y>(
  parent: Traversal<S, T, A, B>,
  child: Traversal<A, B, X, Y>
): Traversal<S, T, X, Y>;
function compose<S, T, A, B, X, Y>(
  parent: Getter<S, A>,
  child: Getter<A, X>
): Getter<S, X>;

TypeScript compiler will traverse the overloads from the top (most specific optics) to the bottom (most general optics) and will choose the most specific result for our composition.

For Getters I just moved the R type parameter down to the optic function. My assumption is that now Getter<S,A> is a Getting<R,S,A> for all Rs (which should be inferred by the compiler from the context)

interface Getter<S, A> {
  readonly $type?: "Getting";
  $applyOptic: <R, FA extends Const<R, A>, FS extends Const<R, S>>(
    F: Functor<A, S, FA, FS>,
    f: Fn<A, FA>,
    s: S
  ) => FS;
}

to converts a normal function to a Getter (so it can be composed with other optics).

function to<S, A>(sa: Fn<S, A>): Getter<S, A> {
  return {
    $applyOptic(F, f, s) {
      return f(sa(s)) as any;
    }
  };
}

For now I’m using sort of hack as any to typecast the result, but it should be safe (because we know the result of applying f is a Const<R,A> which could be safely converted to Const<R,S> since A and S are just phantom types).

Accessor functions

We still need to implement the Functor and Applicative interfaces for Const. But first we need to define the Monoid interface (needed by Const to be an Applicative)

interface Monoid<A> {
  empty: () => A;
  concat: (xs: A[]) => A;
}

The following function implements the Const Functor and Applicative

function ConstM(M) {
  return {
    map(f, k) {
      return k;
    },
    pure: _ => M.empty(),
    combine(_, ks) {
      return M.concat(ks);
    }
  };
}

The definition of map is trivial, we’e just forwarding our constant value (the R in Const<R,A>). For the Applicative definition, we’re relying on a given Monoid M to accumulate the Rs. For example if we consider the List Monoid

const List = {
  empty: () => [],
  concat(xss) {
    return [].concat(...xss);
  }
};

Then I can create a Const Applicative that accumulates all the values into an array

const ConstList = Const(List);

And here is the corresponding accessor function (as always we’re specifying the type parameters at the call site)

function toList<S, A>(l: Getting<A[], S, A>, s: S): A[] {
  return l.$applyOptic(
    ConstList as Applicative<A, S, Const<A[], A>, Const<A[], S>>,
    x => [x] as Const<A[], A>,
    s
  );
}

Using toList, for example, on a Traversal will combine all the values inside using the ConstList Applicative, which under the hoods uses the List Monoid to concatenate the traversed values.

The other functions view, preview and has all have a similar implementation, we use a special instance of the Monoid to provide a different behavior (cf link below for the full implementation).

One caveat is that view doesn’t actually work the same way as in Haskell. Since it can only get one value, if it’s used on a Traversal or Prism it’ll throw an Error (in Haskell the Monoid intance is automatically choosed by the compiler).

Another last minute caveat is that optional $type in Optic interface doesn’t seem to play nice with strictNullChecks enabled. But probably fixable.

Next typings to add