This is the 4th post documenting my tentative to add typings to my focused lens library.
So far, I have type definitions for
- base typeclasses/interfaces (Functor and Applicative)
- Isos
- Lenses
- Prisms
- Traversals
- Lens & Traversal Composition
over
(and by extensionset
)
Next I’ll be adding typing for accessor functions view
, preview
, … this requires typing Getters.
Gettings/Getters
For context, focused
defines four accessor functions.
view(optic, state)
is used to access a single focused value. (for now) The value must exist or it’ll throw an Error.preview(optic, state)
same asview
but returnsnull
if there is no value. If there are many focused values returns the first one.toList(optic, state)
returns all focused values (0 or more).has(optic, state)
returns false if there is no value under the focus, or true otherwise.
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 Getter
s 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 R
s (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 R
s. 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 withstrictNullChecks
enabled. But probably fixable.
Next typings to add
- The Proxy interface
- More awkward multiple composition (composing more than 2 optics)