This implementation can now be found in production here
Last active
December 23, 2022 17:59
-
-
Save baetheus/0fd00a5463620aa6af2c9415fe5cad2b to your computer and use it in GitHub Desktop.
Kliesli Arrow Optics
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This is all based on the idea here: https://gist.github.com/serras/5152ec18ec5223b676cc67cac0e99b70 | |
// | |
import type { $, Kind } from "../kind.ts"; | |
import type { Traversable } from "../traversable.ts"; | |
import type { Refinement } from "../refinement.ts"; | |
import type { Predicate } from "../predicate.ts"; | |
import type { Either } from "../either.ts"; | |
import type { Option } from "../option.ts"; | |
import type { Monad } from "../monad.ts"; | |
import type { Fn } from "../fn.ts"; | |
import * as I from "../identity.ts"; | |
import * as O from "../option.ts"; | |
import * as A from "../array.ts"; | |
import * as R from "../record.ts"; | |
import * as E from "../either.ts"; | |
import { isNotNil } from "../nilable.ts"; | |
import { apply, flow, identity, pipe, todo, unsafeCoerce } from "../fn.ts"; | |
// Build up language of Kliesli Optics | |
/** | |
* Following are the runtime tags associated | |
* with various forms of Optics. | |
*/ | |
export const LensTag = "Lens" as const; | |
export type LensTag = typeof LensTag; | |
export const PrismTag = "Prism" as const; | |
export type PrismTag = typeof PrismTag; | |
export const TraversalTag = "Traversal" as const; | |
export type TraversalTag = typeof TraversalTag; | |
export type OpticTag = LensTag | PrismTag | TraversalTag; | |
/** | |
* Type level mapping from Tag to URI. Since an | |
* Optic get function is a Kliesli Arrow a => mb, we | |
* associate the Optic Tags as follows: | |
* | |
* LensTag => Identity | |
* PrismTag => Option | |
* TraversalTag => Array | |
*/ | |
export type ToURI<T extends OpticTag> = T extends LensTag ? I.URI | |
: T extends PrismTag ? O.URI | |
: T extends TraversalTag ? A.URI | |
: never; | |
/** | |
* Type level mapping from URI to Tag | |
*/ | |
export type ToTag<U extends Kind> = U extends A.URI ? TraversalTag | |
: U extends O.URI ? PrismTag | |
: U extends I.URI ? LensTag | |
: never; | |
/** | |
* Join will give us the "loosest" of two tags. This is used to | |
* determine the abstraction level that an Optic operatates at. The | |
* most contstrained is Identity while the least constrained is Array. | |
* The typescript version of the source optics Getters are as follows: | |
* | |
* ```ts | |
* type Lens<S, A> = { get: (s: S) => Identity<A> }; | |
* type Prism<S, A> = { get: (s: S) => Option<A> }; | |
* type Traversal<S, A> = { get: (s: S) => Array<A> }; | |
* ``` | |
* | |
* Here we can see that Lens is constrained to get exactly one A, | |
* Prism is constrained to get zero or one A, and Traversal is | |
* constrained to get zero, one, or many As. Because of this, | |
* Lens can always be lifted to a Prism and Prism can always be | |
* lifted to Traversal. All Optics share the same modify function | |
* over S and A. | |
* | |
* Thus Join is like GT where Array > Option > Identity. | |
*/ | |
export type Join<U extends OpticTag, V extends OpticTag> = U extends | |
TraversalTag ? TraversalTag | |
: V extends TraversalTag ? TraversalTag | |
: U extends PrismTag ? PrismTag | |
: V extends PrismTag ? PrismTag | |
: LensTag; | |
/** | |
* The runtime level GTE for Join | |
*/ | |
export function join<A extends OpticTag, B extends OpticTag>( | |
a: A, | |
b: B, | |
): Join<A, B> { | |
if (a === TraversalTag || b === TraversalTag) { | |
return TraversalTag as unknown as Join<A, B>; | |
} else if (a === PrismTag || b === PrismTag) { | |
return PrismTag as unknown as Join<A, B>; | |
} else { | |
return LensTag as unknown as Join<A, B>; | |
} | |
} | |
/** | |
* Our new Optic definition. Instead of get and set we use get and modify as | |
* set can be derived from modify(() => value). This drastically simplifies | |
* implementation. | |
*/ | |
export type Optic<T extends OpticTag, S, A> = { | |
readonly tag: T; | |
readonly get: Fn<[S], $<ToURI<T>, [A, never, never], [never], [never]>>; | |
readonly modify: Fn<[Fn<[A], A>], Fn<[S], S>>; | |
}; | |
/** | |
* We recover the Lens type from the generic Optic | |
*/ | |
export type Lens<S, A> = Optic<LensTag, S, A>; | |
/** | |
* We recover the Prism type from the generic Optic | |
*/ | |
export type Prism<S, A> = Optic<PrismTag, S, A>; | |
/** | |
* We recover the Traversal type from the generic Optic | |
*/ | |
export type Traversal<S, A> = Optic<TraversalTag, S, A>; | |
export function optic<U extends OpticTag, S, A>( | |
tag: U, | |
get: Fn<[S], $<ToURI<U>, [A, never, never], [never], [never]>>, | |
modify: Fn<[Fn<[A], A>], Fn<[S], S>>, | |
): Optic<U, S, A> { | |
return { tag, get, modify }; | |
} | |
/** | |
* Construct a Lens from get and modify functions. | |
*/ | |
export function lens<S, A>( | |
get: (s: S) => A, | |
modify: Fn<[Fn<[A], A>], Fn<[S], S>>, | |
): Lens<S, A> { | |
return optic(LensTag, get, modify); | |
} | |
/** | |
* Construct a Prism from get and modify functions. | |
*/ | |
export function prism<S, A>( | |
get: (s: S) => Option<A>, | |
modify: Fn<[Fn<[A], A>], Fn<[S], S>>, | |
): Prism<S, A> { | |
return optic(PrismTag, get, modify); | |
} | |
/** | |
* Construct a Traversal from get and modify functions. | |
*/ | |
export function traversal<S, A>( | |
get: (s: S) => ReadonlyArray<A>, | |
modify: Fn<[Fn<[A], A>], Fn<[S], S>>, | |
): Traversal<S, A> { | |
return optic(TraversalTag, get, modify); | |
} | |
/** | |
* A helper function mapping an Optic tag to it's associate ADT Monad | |
* instance. | |
*/ | |
export function getMonad<U extends OpticTag>(tag: U): Monad<ToURI<U>> { | |
return (tag === TraversalTag | |
? A.MonadArray | |
: tag === PrismTag | |
? O.MonadOption | |
: I.MonadIdentity) as unknown as Monad<ToURI<U>>; | |
} | |
/** | |
* Cast an Optic<U> as an Optic<V>. This non-exported function only | |
* works for the following cases | |
* | |
* Lens => Lens | |
* Lens => Prism | |
* Lens => Traversal | |
* | |
* Prism => Prism | |
* Prism => Traversal | |
* | |
* Traversal => Traversal | |
* | |
* As long as only Optics over Identity, Option, and Array are in | |
* this file. There should be no way to break this casting. | |
*/ | |
function cast<U extends OpticTag, V extends OpticTag, S, A>( | |
G: Optic<U, S, A>, | |
tag: V, | |
): Optic<V, S, A> { | |
// Covers Lens => Lens, Prism => Prism, Traversal => Traversal | |
if (G.tag === tag as OpticTag) { | |
return unsafeCoerce(G); | |
// Covers Lens => Traversal, Prism => Traversal | |
} else if (tag == TraversalTag) { | |
// deno-lint-ignore no-explicit-any | |
return unsafeCoerce(traversal((s) => A.of(G.get(s)) as any, G.modify)); | |
// Covers Lens => Prism | |
} else { | |
// deno-lint-ignore no-explicit-any | |
return unsafeCoerce(prism((s) => O.of(G.get(s)) as any, G.modify)); | |
} | |
} | |
/** | |
* Compose two Optics by lifting them to matching ADTs, then chain | |
* using the Monad for that ADT. Using a monad here was easier than | |
* implementing Arrow all over the fun library | |
*/ | |
export function compose<V extends OpticTag, A, I>(second: Optic<V, A, I>) { | |
return <U extends OpticTag, S>( | |
first: Optic<U, S, A>, | |
): Optic<Join<U, V>, S, I> => { | |
const tag = join(first.tag, second.tag); | |
const _chain = getMonad(tag).chain; | |
const _first = cast(first, tag); | |
const _second = cast(second, tag); | |
return { | |
tag, | |
get: flow(_first.get, _chain(_second.get)), | |
modify: flow(_second.modify, _first.modify), | |
}; | |
}; | |
} | |
/** | |
* The starting place for most Optics. Create an Optic over the | |
* identity function. | |
*/ | |
export function id<A>(): Lens<A, A> { | |
return lens(identity, identity); | |
} | |
/** | |
* Given an Optic over a structure with a property P, construct a | |
* new Optic at that property P. | |
*/ | |
export function prop<A, P extends keyof A>(prop: P) { | |
return <U extends OpticTag, S>( | |
sa: Optic<U, S, A>, | |
): Optic<Join<U, LensTag>, S, A[P]> => | |
pipe( | |
sa, | |
compose( | |
lens((s) => s[prop], (fii) => (a) => ({ ...a, [prop]: fii(a[prop]) })), | |
), | |
); | |
} | |
/** | |
* Given an Optic over a structure with properties P, construct a new | |
* optic that only focuses on those properties | |
*/ | |
export function props<A, P extends keyof A>(...props: [P, P, ...Array<P>]) { | |
return <U extends OpticTag, S>( | |
first: Optic<U, S, A>, | |
): Optic<Join<U, LensTag>, S, { [K in P]: A[K] }> => | |
pipe( | |
first, | |
compose(lens( | |
R.pick(props), | |
(faa) => (a) => ({ ...a, ...faa(pipe(a, R.pick(props))) }), | |
)), | |
); | |
} | |
/** | |
* Given an optic over an array, focus on a value at an index in the | |
* array. | |
*/ | |
export function index(i: number) { | |
return <U extends OpticTag, S, A>( | |
first: Optic<U, S, ReadonlyArray<A>>, | |
): Optic<Join<U, PrismTag>, S, A> => | |
pipe(first, compose(prism(A.lookup(i), A.modifyAt(i)))); | |
} | |
/** | |
* Given an optic over a record, focus on a value at a key in that | |
* record. | |
*/ | |
export function key(key: string) { | |
return <U extends OpticTag, S, A>( | |
first: Optic<U, S, Readonly<Record<string, A>>>, | |
): Optic<Join<U, PrismTag>, S, A> => | |
pipe(first, compose(prism(R.lookup(key), R.modifyAt(key)))); | |
} | |
/** | |
* Given an optic over a record, focus on an Option(value) at | |
* the given key, allowing one to delete the key by modifying | |
* with a None value. | |
* | |
* TODO: Clean this implementation up. | |
*/ | |
export function atKey(key: string) { | |
return <U extends OpticTag, S, A>( | |
first: Optic<U, S, Readonly<Record<string, A>>>, | |
): Optic<Join<U, LensTag>, S, Option<A>> => | |
pipe( | |
first, | |
compose(lens(R.lookupAt(key), (faa) => (s) => | |
pipe( | |
s, | |
R.lookup(key), | |
faa, | |
O.fold(() => R.deleteAt(key), R.insertAt(key)), | |
apply(s), | |
))), | |
); | |
} | |
/** | |
* Given an Optic over a structure with a nilable property P, construct a | |
* new Option Optic at that property P. | |
*/ | |
export function nilableProp<A, P extends keyof A>(prop: P) { | |
return <U extends OpticTag, S>( | |
sa: Optic<U, S, A>, | |
): Optic<Join<U, PrismTag>, S, NonNullable<A[P]>> => | |
pipe( | |
sa, | |
compose(prism((s) => | |
O.fromNullable(s[prop]), (fii) => (a) => | |
isNotNil(a[prop]) | |
? ({ ...a, [prop]: fii(a[prop] as NonNullable<A[P]>) }) | |
: a)), | |
); | |
} | |
/** | |
* Given an Optic focused on A, filter out or refine that A. | |
*/ | |
export function filter<A>( | |
r: Predicate<A>, | |
): <U extends OpticTag, S>( | |
first: Optic<U, S, A>, | |
) => Optic<Join<U, PrismTag>, S, A>; | |
export function filter<A, B extends A>( | |
r: Refinement<A, B>, | |
): <U extends OpticTag, S>( | |
first: Optic<U, S, A>, | |
) => Optic<Join<U, PrismTag>, S, B>; | |
export function filter<A>(predicate: Predicate<A>) { | |
return <U extends OpticTag, S>( | |
first: Optic<U, S, A>, | |
): Optic<Join<U, PrismTag>, S, A> => | |
pipe( | |
first, | |
compose(prism(O.fromPredicate(predicate), (fii) => (a) => | |
predicate(a) ? fii(a) : a)), | |
); | |
} | |
/** | |
* Traverse a U using a Traversable for U. This | |
*/ | |
export function traverse<T extends Kind>(T: Traversable<T>) { | |
return <U extends OpticTag, S, A, B, C, D, E>( | |
first: Optic<U, S, $<T, [A, B, C], [D], [E]>>, | |
): Optic<Join<U, TraversalTag>, S, A> => | |
pipe( | |
first, | |
compose(traversal( | |
T.reduce((as, a) => { | |
// Interior mutability is questionable here | |
as.push(a); | |
return as; | |
}, [] as A[]), | |
(faa) => T.map(faa), | |
)), | |
); | |
} | |
/** | |
* Construct a set function for a given Optic | |
*/ | |
export function set<U extends OpticTag, S, A>( | |
optic: Optic<U, S, A>, | |
): (a: A) => (s: S) => S { | |
return (a) => optic.modify(() => a); | |
} | |
/** | |
* Given an optic focused on an Option<A>, construct | |
* an Optic focused within the Option. | |
*/ | |
export function some<U extends OpticTag, S, A>( | |
optic: Optic<U, S, Option<A>>, | |
): Optic<Join<U, PrismTag>, S, A> { | |
return pipe(optic, compose(prism(identity, O.map))); | |
} | |
/** | |
* Given an optic focused on an Either<B, A>, construct | |
* an Optic focused on the Right value of the Either. | |
*/ | |
export function right<U extends OpticTag, S, B, A>( | |
optic: Optic<U, S, Either<B, A>>, | |
): Optic<Join<U, PrismTag>, S, A> { | |
return pipe(optic, compose(prism(E.getRight, E.map))); | |
} | |
/** | |
* Given an optic focused on an Either<B, A>, construct | |
* an Optic focused on the Left value of the Either. | |
*/ | |
export function left<U extends OpticTag, S, B, A>( | |
optic: Optic<U, S, Either<B, A>>, | |
): Optic<Join<U, PrismTag>, S, B> { | |
return pipe(optic, compose(prism(E.getLeft, E.mapLeft))); | |
} | |
// -- Lets Test --- | |
// Nested structure | |
type Structure = { | |
name: Option<string>; | |
age?: Either<string, number>; | |
first: { | |
second: { | |
third: number; | |
fourth?: { | |
fifth: { | |
sixth: number; | |
seventh: readonly { | |
name: string; | |
}[]; | |
}; | |
}; | |
}; | |
}; | |
}; | |
// Mock the structure | |
const makeStructure = (third: number): Structure => ({ | |
name: O.none, | |
age: E.left("No age listed"), | |
first: { second: { third } }, | |
}); | |
const makeDeepStructure = (third: number, names: string[]): Structure => ({ | |
name: O.some("Brandon"), | |
age: E.right(37), | |
first: { | |
second: { | |
third, | |
fourth: { | |
fifth: { sixth: 2 * third, seventh: names.map((name) => ({ name })) }, | |
}, | |
}, | |
}, | |
}); | |
// Get a nested value from a structure | |
const structure = makeStructure(5); | |
const deepStructure = makeDeepStructure(100, [ | |
"Brandon", | |
"Tina", | |
"Rufus", | |
"Clementine", | |
]); | |
const ot1 = pipe( | |
id<Structure>(), | |
prop("name"), | |
some, | |
); | |
console.log({ | |
test: "ot1", | |
get: ot1.get(structure), | |
getDeep: ot1.get(deepStructure), | |
modify: pipe(structure, ot1.modify((a) => a.toUpperCase())), | |
modifyDeep: pipe(deepStructure, ot1.modify((a) => a.toUpperCase())), | |
}); | |
const et1 = pipe( | |
id<Structure>(), | |
nilableProp("age"), | |
right, | |
); | |
console.log({ | |
test: "et1", | |
get: et1.get(structure), | |
getDeep: et1.get(deepStructure), | |
modify: pipe(structure, et1.modify((a) => a + 1)), | |
modifyDeep: pipe(deepStructure, et1.modify((a) => a + 1)), | |
}); | |
const et2 = pipe( | |
id<Structure>(), | |
nilableProp("age"), | |
left, | |
); | |
console.log({ | |
test: "et2", | |
get: et2.get(structure), | |
getDeep: et2.get(deepStructure), | |
modify: pipe(structure, et2.modify((a) => a.toUpperCase())), | |
modifyDeep: pipe(deepStructure, et2.modify((a) => a.toUpperCase())), | |
}); | |
const t1 = pipe( | |
id<Structure>(), | |
prop("first"), | |
prop("second"), | |
prop("third"), | |
filter((n) => n > 0), | |
); | |
const t2 = pipe( | |
id<Structure>(), | |
prop("first"), | |
prop("second"), | |
nilableProp("fourth"), | |
prop("fifth"), | |
prop("sixth"), | |
); | |
const _ = pipe( | |
id<Structure>(), | |
prop("first"), | |
prop("second"), | |
prop("fourth"), // Returns Optic<LensTag, Structure, { fifth: { sixth: number } } | undefined> | |
// prop("fifth"), // Fails with string not assignable to never | |
// prop("sixth"), | |
); | |
const t3 = pipe( | |
id<Structure>(), | |
prop("first"), | |
prop("second"), | |
nilableProp("fourth"), | |
prop("fifth"), | |
prop("seventh"), | |
traverse(A.TraversableArray), | |
prop("name"), | |
); | |
// Returns: | |
// { | |
// structure: { first: { second: { third: 5 } } }, | |
// t1: { tag: "Some", value: 5 }, | |
// t2: { tag: "None" }, | |
// t3: [], | |
// t3deep: [ "Brandon", "Tina", "Rufus", "Clementine" ] | |
// } | |
console.log({ | |
structure, | |
t1: t1.get(structure), | |
t2: t2.get(structure), | |
t3: t3.get(structure), | |
t3deep: t3.get(deepStructure), | |
}); | |
const modifiedDeep = pipe( | |
deepStructure, | |
t3.modify((name) => name.toUpperCase()), | |
); | |
// { | |
// "first": { | |
// "second": { | |
// "third": 100, | |
// "fourth": { | |
// "fifth": { | |
// "sixth": 200, | |
// "seventh": [ | |
// { | |
// "name": "BRANDON" | |
// }, | |
// { | |
// "name": "TINA" | |
// }, | |
// { | |
// "name": "RUFUS" | |
// }, | |
// { | |
// "name": "CLEMENTINE" | |
// } | |
// ] | |
// } | |
// } | |
// } | |
// } | |
// } | |
console.log(JSON.stringify(modifiedDeep, null, 2)); | |
const traverseFilter = pipe(t3, filter((name) => name.includes("a"))); | |
const tfModify = pipe( | |
deepStructure, | |
traverseFilter.modify((name) => name.toUpperCase()), | |
); | |
console.log({ | |
deepStructure, | |
traverseFilter: traverseFilter.get(deepStructure), | |
}); | |
console.log(JSON.stringify(tfModify, null, 2)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment