Typing Optics (3): Isomorphisms and Prisms

This is the 3rd post documenting my tentative to add typings to my focused lens library.

So far, I’ve been able to add type definitions for

In this post I’ll be tackling Isomorphisms and Prisms

Isomorphisms

If we look at the typical definition of Isos in Haskell

type Iso s t a b = forall p f. (Profunctor p, Functor f) => p a (f b) -> p s (f t)

There is a story behind this representation. You can read it in detail in the linked post (but it’s not required to follow the rest).

To simplify, an Iso can be represented in 2 ways: functional & concrete . The concrete representation matches our intuition about Isos, a pair of inversible functions

data CIso a s = CIso (s -> a) (a -> s)
-- or using the polymorphic version
data CIso a b s t = CIso (s -> a) (b -> t)

The functional representation is similar to Lenses

type FIso s t a b = (a -> f b) -> s -> f t

We need Isos to be both above representations at once. In Haskell we do this by creating a typeclass (something like an interface) that abstracts over both representations and instantiate (implement) the typeclass for each concrete representation. For Isos, the typeclass used is the Profunctor class. By making both functions and CIso instances of it, we can rely on Haskell type inference to choose the appropriate representation (for the interested here is an an implementation example, look at the file Iso.hs).

Now, in TypeScript, I don’t actually want to introduce Profunctors in the library for various reasons. But I can have both representation as part of the same interface.

interface Iso<S, T, A, B> {
  readonly $type?: "Iso" & "Lens" & "Traversal";
  $applyOptic: (<FB, FT>(F: Functor<B, T, FB, FT>, f: Fn<A, FB>, s: S) => FT);
  from: (s: S) => A;
  to: (b: B) => T;
}

Each Iso is a also a Lens (and by extension a Traversal), since the functional implementation is the same as the Lens one.

Now the trick is I can always construct the functional representation given the concrete representation

function iso<S, T, A, B>(from: (s: S) => A, to: (b: B) => T): Iso<S, T, A, B> {
  return {
    $applyOptic(F, f, s) {
      return F.map(to, f(from(s)));
    },
    from,
    to
  };
}

And I can inverse an Iso using the embedded pair of functions

function from<S, T, A, B>(anIso: Iso<S, T, A, B>): Iso<B, A, T, S> {
  return iso(anIso.to, anIso.from);
}

But there is one caveat, if I compose an Iso with another Iso it’ll only give me the composed function $applyOptic, so the original from and to are gone. If I want to preserve the Isomorphism when composing 2 Isos, I’ll have to modify the compose function to handle this special case. In fact, this what’s done in the actual implementation.

Since now we have 3 optic types, normally we’d have to write 9 overloads for compose but as we already saw in the previous post, we need only 3 overloads (since every Iso is a Lens and a Traversal).

function compose<S, T, A, B, X, Y>(
  parent: Iso<S, T, A, B>,
  child: Iso<A, B, X, Y>
): Iso<S, T, X, Y>;
function compose<S, T, A, B, X, Y>(
  parent: Lens<S, T, A, B>,
  child: Lens<A, B, X, Y>
): Lens<S, T, X, Y>;
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>;

We don’t need to modify the definition of over since it takes the most general type (Traversal).

Prisms

The definition of Prisms follows the same path. The Haskell definition is

type Prism s t a b = forall p f.(Choice p, Applicative f) => p a (f b) -> p s (f t)

It’s the same as Isos except we use Choice in place of Profunctor. Choice is also a Profunctor but extends it with additional functions to deal with Sum types like Either (you can read the full story here).

The concrete representation for Prisms is

data CPrism s a = CPrism (s -> Either s a) (a -> s)
-- Polymorphic version
data CPrism s t a b = CPrism (s -> Either t a) (b -> t)

Again it’s the same as Iso except that instead of s -> a in Isos, we have now a s -> Either t a (while an Iso always succeeds in extracting an a from s, a Prism can fail in which case it returns an alternative t that short-circuits the function b -> t).

Since we need to preserve Prisms over composition, we’ll follow the similar trick we did with Isos.

type Either<A, B> = { type: "Left"; value: A } | { type: "Right"; value: B };

interface Prism<S, T, A, B> {
  readonly $type?: "Prism" & "Traversal";
  $applyOptic: (<FB, FT>(
    F: Applicative<B, T, FB, FT>,
    f: Fn<A, FB>,
    s: S
  ) => FT);
  match: (s: S) => Either<T, A>;
  build: (b: B) => T;
}

Notice the function takes an Applicative and not a Functor, we’ll see why in a minute.

prism function is used to construct a functional representation from a concrete one

function prism<S, T, A, B>(
  match: (s: S) => Either<T, A>,
  build: (b: B) => T
): Prism<S, T, A, B> {
  return {
    $applyOptic(F, f, s) {
      const eta = match(s);
      if (eta.type === "Left") {
        // here!!
        return F.pure(eta.value);
      } else {
        return F.map(build, f(eta.value));
      }
    },
    match,
    build
  };
}

F.pure(eta.value) explains why we need an Applicative. In case the match fails in extracting a value from S, we get a T (wrapped in Either), since we need to return an F<T> from T, the pure function from the Applicative interface allows us to wrap plain values into the Applicative context (In fact we don’t need the whole Applicative just the pure part, this restricted interface is sometimes called Pointed).

As for compose we need to add only one overload, if you consulted the implementation of compose linked in the previous section, you’ve already seen that there is also a special case analysis for composing 2 prisms.

function compose<S, T, A, B, X, Y>(
  parent: Prism<S, T, A, B>,
  child: Prism<A, B, X, Y>
): Iso<S, T, X, Y>;

So far, it seems we have typings for the 4 optics, Remaining: