diff --git a/.changeset/simplify-optic-composition.md b/.changeset/simplify-optic-composition.md new file mode 100644 index 00000000000..1ed7423a9ad --- /dev/null +++ b/.changeset/simplify-optic-composition.md @@ -0,0 +1,9 @@ +--- +"effect": patch +--- + +Fix three issues in the public `Optic` API: + +- Composed `Iso` and `Prism` setters no longer try to read a source value before writing. +- Calling `notUndefined` on an `Optional` now returns an `Optional`, because writing can still fail. +- The internal `node` property is no longer exposed by public optic types. diff --git a/packages/effect/benchmark/schema/Optic.ts b/packages/effect/benchmark/schema/Optic.ts index 0581d5fa332..5d474177228 100644 --- a/packages/effect/benchmark/schema/Optic.ts +++ b/packages/effect/benchmark/schema/Optic.ts @@ -1,19 +1,24 @@ import { Optic, Schema } from "effect" import { Bench } from "tinybench" -/* -┌─────────┬──────────────────┬──────────────────┬──────────────────┬────────────────────────┬────────────────────────┬──────────┐ -│ (index) │ Task name │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Throughput med (ops/s) │ Samples │ -├─────────┼──────────────────┼──────────────────┼──────────────────┼────────────────────────┼────────────────────────┼──────────┤ -│ 0 │ 'iso get' │ '907.53 ± 1.06%' │ '834.00 ± 1.00' │ '1159005 ± 0.02%' │ '1199041 ± 1439' │ 1101891 │ -│ 1 │ 'optic get' │ '32.79 ± 0.20%' │ '42.00 ± 1.00' │ '25353263 ± 0.00%' │ '23809524 ± 580720' │ 30500447 │ -│ 2 │ 'direct get' │ '23.12 ± 0.48%' │ '41.00 ± 1.00' │ '32734753 ± 0.01%' │ '24390244 ± 580720' │ 43255789 │ -│ 3 │ 'iso replace' │ '2693.0 ± 2.87%' │ '2459.0 ± 41.00' │ '396398 ± 0.03%' │ '406669 ± 6669' │ 371349 │ -│ 4 │ 'direct replace' │ '848.59 ± 0.45%' │ '792.00 ± 1.00' │ '1244301 ± 0.02%' │ '1262626 ± 1596' │ 1178430 │ -└─────────┴──────────────────┴──────────────────┴──────────────────┴────────────────────────┴────────────────────────┴──────────┘ -*/ +// Batching bounds sample storage and keeps sub-microsecond timings above timer resolution. +const batchSize = 1_000 +const bench = new Bench({ + iterations: 1_000, + time: 0, + warmupIterations: 100, + warmupTime: 0, + timestampProvider: "hrtimeNow" +}) +let sink: unknown -const bench = new Bench() +const batch = (run: () => A) => () => { + let value = run() + for (let index = 1; index < batchSize; index++) { + value = run() + } + sink = value +} // Define a class with nested properties class User extends Schema.Class("User")({ @@ -47,33 +52,53 @@ const iso = Schema.toIso(User).key("profile").key("address").key("street") const optic = Optic.id().key("profile").key("address").key("street") bench - .add("iso get", function() { - iso.get(user) - }) - .add("optic get", function() { - optic.get(user) - }) - .add("direct get", function() { - // oxlint-disable-next-line no-unused-expressions - user.profile.address.street - }) - .add("iso replace", function() { - iso.replace("Updated", user) - }) - .add("direct replace", function() { - // oxlint-disable-next-line no-new - new User({ - ...user, - profile: { - ...user.profile, - address: { - ...user.profile.address, - street: "Updated" + .add("iso get", batch(() => iso.get(user))) + .add("optic get", batch(() => optic.get(user))) + .add("direct get", batch(() => user.profile.address.street)) + .add("iso replace", batch(() => iso.replace("Updated", user))) + .add( + "direct replace", + batch(() => + new User({ + ...user, + profile: { + ...user.profile, + address: { + ...user.profile.address, + street: "Updated" + } } - } - }) - }) + }) + ) + ) await bench.run() -console.table(bench.table()) +if (sink === undefined) { + throw new Error("Benchmark did not run") +} + +console.table(bench.table((task) => { + const result = task.result + if (result?.state === "errored") { + return { + "Task name": task.name, + Error: result.error.message + } + } + if (result?.state !== "completed") { + return { + "Task name": task.name, + State: result?.state ?? "missing result" + } + } + const latencyToNs = (value: number) => value * 1_000_000 / batchSize + return { + "Task name": task.name, + "Latency avg (ns/op)": latencyToNs(result.latency.mean).toFixed(2), + "Latency med (ns/op)": latencyToNs(result.latency.p50).toFixed(2), + "Latency RME": `${result.latency.rme.toFixed(2)}%`, + "Throughput avg (ops/s)": Math.round(result.throughput.mean * batchSize), + Samples: result.latency.samplesCount + } +})) diff --git a/packages/effect/src/Optic.ts b/packages/effect/src/Optic.ts index aa57a6dd5a6..c7bce669244 100644 --- a/packages/effect/src/Optic.ts +++ b/packages/effect/src/Optic.ts @@ -13,7 +13,7 @@ */ import { format } from "./Formatter.ts" -import { identity, memoize } from "./Function.ts" +import { identity } from "./Function.ts" import * as InternalRecord from "./internal/record.ts" import * as Option from "./Option.ts" import * as Predicate from "./Predicate.ts" @@ -103,7 +103,7 @@ export interface Iso extends Lens, Prism {} * @since 4.0.0 */ export function makeIso(get: (s: S) => A, set: (a: A) => S): Iso { - return make(new IsoNode(get, set)) + return make(primitiveNode("Iso", get, set)) } /** @@ -183,7 +183,7 @@ export interface Lens extends Optional { * @since 4.0.0 */ export function makeLens(get: (s: S) => A, replace: (a: A, s: S) => S): Lens { - return make(new LensNode(get, replace)) + return make(primitiveNode("Lens", get, replace)) } /** @@ -273,7 +273,7 @@ export interface Prism extends Optional { * @since 4.0.0 */ export function makePrism(getResult: (s: S) => Result.Result, set: (a: A) => S): Prism { - return make(new PrismNode(getResult, set)) + return make(primitiveNode("Prism", getResult, set)) } /** @@ -315,142 +315,95 @@ export function makePrism(getResult: (s: S) => Result.Result, s * @since 4.0.0 */ export function fromChecks(...checks: readonly [SchemaAST.Check, ...Array>]): Prism { - return make(new CheckNode(checks)) + return make([new CheckNode(checks)]) } -type Node = - | IdentityNode - | IsoNode - | LensNode - | PrismNode - | OptionalNode - | PathNode - | CheckNode - | CompositionNode +type Kind = "Iso" | "Lens" | "Prism" | "Optional" -class IdentityNode { - readonly _tag = "IdentityNode" +type Operation = { + readonly kind: Kind + readonly get: (s: any) => any + readonly set: (a: any, s?: any) => any } -const identityNode = new IdentityNode() - -class CompositionNode { - readonly _tag = "CompositionNode" - readonly nodes: readonly [Node, ...Array] - - constructor(nodes: readonly [Node, ...Array]) { - this.nodes = nodes - } +type PrimitiveStep = Operation & { + readonly _tag: "PrimitiveNode" } -class IsoNode { - readonly _tag = "IsoNode" - readonly get: (s: S) => A - readonly set: (a: A) => S - - constructor(get: (s: S) => A, set: (a: A) => S) { - this.get = get - this.set = set - } -} +type Step = PrimitiveStep | PathNode | CheckNode -class LensNode { - readonly _tag = "LensNode" - readonly get: (s: S) => A - readonly set: (a: A, s: S) => S +type Node = ReadonlyArray - constructor(get: (s: S) => A, set: (a: A, s: S) => S) { - this.get = get - this.set = set - } +function primitiveNode(kind: Kind, get: (s: any) => any, set: (a: any, s?: any) => any): Node { + return [{ _tag: "PrimitiveNode", kind, get, set }] } -class PrismNode { - readonly _tag = "PrismNode" - readonly get: (s: S) => Result.Result - readonly set: (a: A) => S - - constructor(get: (s: S) => Result.Result, set: (a: A) => S) { - this.get = get - this.set = set - } -} - -class OptionalNode { - readonly _tag = "OptionalNode" - readonly get: (s: S) => Result.Result - readonly set: (a: A, s: S) => Result.Result - - constructor(get: (s: S) => Result.Result, set: (a: A, s: S) => Result.Result) { - this.get = get - this.set = set - } +const identityOperation: Operation = { + kind: "Iso", + get: identity, + set: identity } class PathNode { readonly _tag = "PathNode" + readonly kind = "Lens" readonly path: ReadonlyArray + readonly get: (s: any) => any + readonly set: (a: any, s?: any) => any constructor(path: ReadonlyArray) { this.path = path + this.get = (s) => { + let out = s + for (let i = 0; i < path.length; i++) { + out = out[path[i]] + } + return out + } + this.set = (a, s) => { + const out = cloneShallow(s) + let current = out + let i = 0 + for (; i < path.length - 1; i++) { + const key = path[i] + InternalRecord.assignProperty(current, key, cloneShallow(current[key])) + current = current[key] + } + InternalRecord.assignProperty(current, path[i], a) + return out + } } } class CheckNode { readonly _tag = "CheckNode" + readonly kind = "Prism" readonly checks: readonly [SchemaAST.Check, ...Array>] + readonly get: (s: T) => Result.Result + readonly set = identity constructor(checks: readonly [SchemaAST.Check, ...Array>]) { this.checks = checks + this.get = (s) => Result.mapError(SchemaAST.runChecks(checks, s), String) } } -// Nodes that can appear in a normalized chain (no Identity/Composition) -type NormalizedNode = Exclude - -// Fuse with tail when possible, else push. -function pushNormalized(acc: Array, node: NormalizedNode): void { - const last = acc[acc.length - 1] - if (last) { +function compose(a: Node, b: Node): Node { + if (a.length === 0) return b + if (b.length === 0) return a + const nodes = a.slice() + for (let i = 0; i < b.length; i++) { + const node = b[i] + const last = nodes[nodes.length - 1] if (last._tag === "PathNode" && node._tag === "PathNode") { - // fuse Path - acc[acc.length - 1] = new PathNode([...last.path, ...node.path]) - return + nodes[nodes.length - 1] = new PathNode([...last.path, ...node.path]) + } else if (last._tag === "CheckNode" && node._tag === "CheckNode") { + nodes[nodes.length - 1] = new CheckNode([...last.checks, ...node.checks]) + } else { + nodes.push(node) } - if (last._tag === "CheckNode" && node._tag === "CheckNode") { - // fuse Checks - acc[acc.length - 1] = new CheckNode([...last.checks, ...node.checks]) - return - } - } - acc.push(node) -} - -// Collect nodes from a node into `acc`, flattening & normalizing on the fly. -function collect(node: Node, acc: Array): void { - if (node._tag === "IdentityNode") return - if (node._tag === "CompositionNode") { - // flatten without extra arrays - for (let i = 0; i < node.nodes.length; i++) collect(node.nodes[i], acc) - return - } - // primitive node - pushNormalized(acc, node) -} - -function compose(a: Node, b: Node): Node { - const nodes: Array = [] - collect(a, nodes) - collect(b, nodes) - - switch (nodes.length) { - case 0: - return identityNode - case 1: - return nodes[0] - default: - return new CompositionNode(nodes as [Node, ...Array]) } + return nodes } type ForbidUnion = IsUnion extends true ? [Message] : [] @@ -501,7 +454,6 @@ type ForbidUnion = IsUnion extends true ? [Message * @since 4.0.0 */ export interface Optional { - readonly node: Node /** * Attempts to read the focus `A` from the whole `S`. Returns * `Result.Success` when the focus exists, or @@ -862,8 +814,8 @@ export interface Optional { * * @since 4.0.0 */ - notUndefined(): Prism> - notUndefined(): Optional> + notUndefined(this: Prism): Prism> + notUndefined(this: Optional): Optional> /** * Focuses **all elements** of an array-like focus and optionally narrows @@ -988,7 +940,7 @@ export function makeOptional( getResult: (s: S) => Result.Result, set: (a: A, s: S) => Result.Result ): Optional { - return make(new OptionalNode(getResult, set)) + return make(primitiveNode("Optional", getResult, set)) } /** @@ -1034,6 +986,7 @@ export function makeOptional( export interface Traversal extends Optional> {} class OptionalImpl implements Optional { + /** @internal */ readonly node: Node readonly getResult: (s: S) => Result.Result readonly replaceResult: (a: A, s: S) => Result.Result @@ -1056,13 +1009,14 @@ class OptionalImpl implements Optional { return make(compose(this.node, that.node)) } key(key: PropertyKey): any { - return make(compose(this.node, new PathNode([key]))) + return make(compose(this.node, [new PathNode([key])])) } optionalKey(key: PropertyKey): any { return make( compose( this.node, - new LensNode( + primitiveNode( + "Lens", (s) => s[key], (a, s) => { const copy = cloneShallow(s) @@ -1082,16 +1036,17 @@ class OptionalImpl implements Optional { ) } check(...checks: readonly [SchemaAST.Check, ...Array>]): any { - return make(compose(this.node, new CheckNode(checks))) + return make(compose(this.node, [new CheckNode(checks)])) } refine(refinement: (a: A) => a is B, annotations?: Schema.Annotations.Filter): any { - return make(compose(this.node, new CheckNode([SchemaAST.makeFilterByGuard(refinement, annotations)]))) + return make(compose(this.node, [new CheckNode([SchemaAST.makeFilterByGuard(refinement, annotations)])])) } tag(tag: string): any { return make( compose( this.node, - new PrismNode( + primitiveNode( + "Prism", (s) => s._tag === tag ? Result.succeed(s) @@ -1106,7 +1061,8 @@ class OptionalImpl implements Optional { return make( compose( this.node, - new OptionalNode( + primitiveNode( + "Optional", (s) => Object.hasOwn(s, key) ? Result.succeed(s[key]) : err, (a, s) => { if (Object.hasOwn(s, key)) { @@ -1127,7 +1083,7 @@ class OptionalImpl implements Optional { omit(keys: any) { return this.compose(makeLens(Struct.omit(keys), (o, a) => ({ ...a, ...o }))) } - notUndefined(): Prism> { + notUndefined(): any { return this.refine(Predicate.isNotUndefined, { expected: "a value other than `undefined`" }) } forEach(this: Traversal, f: (iso: Iso) => Optional): Traversal { @@ -1225,15 +1181,23 @@ class PrismImpl extends OptionalImpl implements Prism { } function make(node: Node): any { - const op = recur(node) - switch (op._tag) { - case "IsoNode": + let op: Operation = node[0] ?? identityOperation + if (node.length > 1) { + const kind = node.reduce((kind, step) => composeKind(kind, step.kind), "Iso") + op = { + kind, + get: compileGet(node, kind), + set: compileSet(node, kind) + } + } + switch (op.kind) { + case "Iso": return new IsoImpl(node, op.get, op.set) - case "LensNode": + case "Lens": return new LensImpl(node, op.get, op.set) - case "PrismNode": + case "Prism": return new PrismImpl(node, op.get, op.set) - case "OptionalNode": + case "Optional": return new OptionalImpl(node, op.get, op.set) } } @@ -1250,135 +1214,79 @@ function cloneShallow(pojo: T): T { return pojo } -type Op = { - readonly _tag: "IsoNode" | "LensNode" | "PrismNode" | "OptionalNode" - readonly get: (s: unknown) => any - readonly set: (a: unknown, s?: unknown) => any +function compileGet(nodes: Node, kind: Kind): (s: any) => any { + return (s) => { + for (let i = 0; i < nodes.length; i++) { + const op = nodes[i] + const result = op.get(s) + if (hasFailingGet(op.kind)) { + if (Result.isFailure(result)) { + return result + } + s = result.success + } else { + s = result + } + } + return hasFailingGet(kind) ? Result.succeed(s) : s + } } -const recur = memoize((node: Node): Op => { - switch (node._tag) { - case "IdentityNode": - return { _tag: "IsoNode", get: identity, set: identity } - case "IsoNode": - case "LensNode": - case "PrismNode": - case "OptionalNode": - return { _tag: node._tag, get: node.get, set: node.set } - case "PathNode": { - return { - _tag: "LensNode", - get: (s: any) => { - const path = node.path - let out: any = s - for (let i = 0, n = path.length; i < n; i++) { - out = out[path[i]] - } - return out - }, - set: (a: any, s: any) => { - const path = node.path - const out = cloneShallow(s) - - let current = out - let i = 0 - for (; i < path.length - 1; i++) { - const key = path[i] - InternalRecord.assignProperty(current, key, cloneShallow(current[key])) - current = current[key] - } - - const finalKey = path[i] - InternalRecord.assignProperty(current, finalKey, a) - - return out - } +function compileSet(nodes: Node, kind: Kind): (a: any, s: any) => any { + if (hasSourceFreeSet(kind)) { + return (a) => { + for (let i = nodes.length - 1; i >= 0; i--) { + a = nodes[i].set(a) } + return a } - case "CheckNode": - return { - _tag: "PrismNode", - get: (s: any) => Result.mapError(SchemaAST.runChecks(node.checks, s), String), - set: identity + } + return (a, s) => { + const len = nodes.length + const sources = new Array(len) + for (let i = 0; i < len; i++) { + sources[i] = s + const op = nodes[i] + if (hasFailingGet(op.kind)) { + const result = op.get(s) + if (Result.isFailure(result)) { + return result + } + s = result.success + } else { + s = op.get(s) } - case "CompositionNode": { - const ops = node.nodes.map(recur) - const _tag = ops.reduce((tag, op) => getCompositionTag(tag, op._tag), "IsoNode") - return { - _tag, - get: (s: any) => { - for (let i = 0; i < ops.length; i++) { - const op = ops[i] - const result = op.get(s) - if (hasFailingGet(op._tag)) { - if (Result.isFailure(result)) { - return result - } - s = result.success - } else { - s = result - } - } - return hasFailingGet(_tag) ? Result.succeed(s) : s - }, - set: (a: any, s: any) => { - const source = s - const len = ops.length - const ss = new Array(len + 1) - ss[0] = s - for (let i = 0; i < len; i++) { - const op = ops[i] - if (hasFailingGet(op._tag)) { - const result = op.get(s) - if (Result.isFailure(result)) { - return _tag === "OptionalNode" ? result : source - } - s = result.success - } else { - s = op.get(s) - } - ss[i + 1] = s - } - for (let i = len - 1; i >= 0; i--) { - const op = ops[i] - if (hasSet(op._tag)) { - a = op.set(a) - } else if (op._tag === "LensNode") { - a = op.set(a, ss[i]) - } else { - const result = op.set(a, ss[i]) - if (Result.isFailure(result)) { - return result - } - a = result.success - } - } - return _tag === "OptionalNode" ? Result.succeed(a) : a + } + for (let i = len - 1; i >= 0; i--) { + const op = nodes[i] + if (hasSourceFreeSet(op.kind)) { + a = op.set(a) + } else if (op.kind === "Lens") { + a = op.set(a, sources[i]) + } else { + const result = op.set(a, sources[i]) + if (Result.isFailure(result)) { + return result } + a = result.success } } + return kind === "Optional" ? Result.succeed(a) : a } -}) +} -function hasFailingGet(tag: Op["_tag"]): boolean { - return tag === "PrismNode" || tag === "OptionalNode" +function hasFailingGet(kind: Kind): boolean { + return kind === "Prism" || kind === "Optional" } -function hasSet(tag: Op["_tag"]): boolean { - return tag === "IsoNode" || tag === "PrismNode" +function hasSourceFreeSet(kind: Kind): boolean { + return kind === "Iso" || kind === "Prism" } -function getCompositionTag(a: Op["_tag"], b: Op["_tag"]): Op["_tag"] { - switch (a) { - case "IsoNode": - return b - case "LensNode": - return hasFailingGet(b) ? "OptionalNode" : "LensNode" - case "PrismNode": - return hasSet(b) ? "PrismNode" : "OptionalNode" - case "OptionalNode": - return "OptionalNode" - } +function composeKind(a: Kind, b: Kind): Kind { + if (a === "Iso") return b + if (b === "Iso" || a === b) return a + return "Optional" } // --------------------------------------------- // Derived APIs @@ -1435,7 +1343,7 @@ export function getAll(traversal: Traversal): (s: S) => Array { // Built-in Optics // --------------------------------------------- -const identityIso = make(identityNode) +const identityIso = make([]) /** * Iso that focuses on the whole value unchanged. @@ -1508,7 +1416,7 @@ export function id(): Iso { * @since 4.0.0 */ export function entries(): Iso, ReadonlyArray> { - return make(new IsoNode(Object.entries, Object.fromEntries)) + return make(primitiveNode("Iso", Object.entries, Object.fromEntries)) } /** diff --git a/packages/effect/test/Optic.test.ts b/packages/effect/test/Optic.test.ts index c15b8825537..d8e900e6d35 100644 --- a/packages/effect/test/Optic.test.ts +++ b/packages/effect/test/Optic.test.ts @@ -27,6 +27,22 @@ describe("Optic", () => { } }) + it("preserves __proto__ as an own property", () => { + const value = { polluted: true } + const optics: ReadonlyArray, object]> = [ + [Optic.id>().key("__proto__"), {}], + [Optic.id>().optionalKey("__proto__"), {}], + [Optic.id>().at("__proto__"), { ["__proto__"]: 0 }] + ] + + for (const [optic, source] of optics) { + const out = optic.replace(value, source) + strictEqual(Object.getPrototypeOf(out), Object.prototype) + assertTrue(Object.hasOwn(out, "__proto__")) + strictEqual(out["__proto__"], value) + } + }) + it("id", () => { const iso = Optic.id() @@ -35,6 +51,108 @@ describe("Optic", () => { strictEqual(iso.modify(addOne)(1), 2) }) + describe("compose", () => { + it("sets through composed isos without reading a source", () => { + const value = Optic.makeIso<{ readonly value: number }, number>( + (s) => s.value, + (value) => ({ value }) + ) + const text = Optic.makeIso( + String, + Number + ) + const optic = value.compose(text) + + deepStrictEqual(optic.set("2"), { value: 2 }) + deepStrictEqual(optic.replace("2", { value: 1 }), { value: 2 }) + }) + + it("sets through composed prisms without reading a source", () => { + const value = Optic.makePrism<{ readonly value: number }, number>( + (s) => Result.succeed(s.value), + (value) => ({ value }) + ) + const text = Optic.makePrism( + (n) => Result.succeed(String(n)), + Number + ) + const optic = value.compose(text) + + deepStrictEqual(optic.set("2"), { value: 2 }) + }) + + it("preserves capabilities and behavior across every optic kind", () => { + const optics = { + Iso: Optic.makeIso((n) => n, (n) => n), + Lens: Optic.makeLens((n) => n, (n) => n), + Prism: Optic.makePrism(Result.succeed, (n) => n), + Optional: Optic.makeOptional(Result.succeed, (n) => Result.succeed(n)) + } as const + const expected = { + Iso: { Iso: "Iso", Lens: "Lens", Prism: "Prism", Optional: "Optional" }, + Lens: { Iso: "Lens", Lens: "Lens", Prism: "Optional", Optional: "Optional" }, + Prism: { Iso: "Prism", Lens: "Optional", Prism: "Prism", Optional: "Optional" }, + Optional: { Iso: "Optional", Lens: "Optional", Prism: "Optional", Optional: "Optional" } + } as const + + for (const left of Object.keys(optics) as Array) { + for (const right of Object.keys(optics) as Array) { + const optic: any = optics[left].compose(optics[right] as any) + const kind = expected[left][right] + + strictEqual("get" in optic, kind === "Iso" || kind === "Lens") + strictEqual("set" in optic, kind === "Iso" || kind === "Prism") + assertSuccess(optic.getResult(1), 1) + assertSuccess(optic.replaceResult(2, 1), 2) + } + } + }) + + it("preserves optional setter failures", () => { + const optic = Optic.makeOptional( + Result.succeed, + () => Result.fail("cannot replace") + ).compose(Optic.makeIso((n) => n, (n) => n)) + + assertFailure(optic.replaceResult(2, 1), "cannot replace") + strictEqual(optic.replace(2, 1), 1) + strictEqual(optic.modify(addOne)(1), 1) + }) + + it("composes identity in both directions", () => { + const value = Optic.makeIso(String, Number) + const left = Optic.id().compose(value) + const right = value.compose(Optic.id()) + const identity = Optic.id().compose(Optic.id()) + + strictEqual(left.get(1), "1") + strictEqual(right.get(1), "1") + strictEqual(left.set("2"), 2) + strictEqual(right.set("2"), 2) + strictEqual(identity.get(1), 1) + strictEqual(identity.set(2), 2) + }) + + it("is independent of composition grouping", () => { + const wrapped = Optic.makeIso<{ readonly value: number }, number>( + (s) => s.value, + (value) => ({ value }) + ) + const text = Optic.makeIso(String, Number) + const chars = Optic.makeIso>( + (s) => [...s], + (chars) => chars.join("") + ) + const left = wrapped.compose(text).compose(chars) + const right = wrapped.compose(text.compose(chars)) + + deepStrictEqual(left.get({ value: 12 }), ["1", "2"]) + deepStrictEqual(right.get({ value: 12 }), ["1", "2"]) + deepStrictEqual(left.set(["3", "4"]), { value: 34 }) + deepStrictEqual(right.set(["3", "4"]), { value: 34 }) + }) + }) + describe("key", () => { describe("Struct", () => { it("required key", () => { @@ -224,6 +342,18 @@ Expected a value greater than 0, got -1.1` deepStrictEqual(optic.modify(addOne)(1.1), 1.1) deepStrictEqual(optic.modify(addOne)(-1.1), -1.1) }) + + it("combines checks across successive calls", () => { + const optic = Optic.id() + .check(Schema.isInt()) + .check(Schema.isGreaterThan(0)) + + assertFailure( + optic.getResult(-1.1), + `Expected an integer, got -1.1 +Expected a value greater than 0, got -1.1` + ) + }) }) it("refine", () => { @@ -332,6 +462,29 @@ Expected a value greater than 0, got -1.1` { a: 0, b: 2, c: 0 } ) }) + + it("fails when the replacement count does not match", () => { + const optic = Optic.id>().forEach((element) => element) + + assertFailure( + optic.replaceResult([2], [1, 2]), + "each: replacement length mismatch: 1 !== 2" + ) + }) + + it("fails when an inner setter fails", () => { + const optic = Optic.id>().forEach(() => + Optic.makeOptional( + Result.succeed, + () => Result.fail("cannot replace") + ) + ) + + assertFailure( + optic.replaceResult([2], [1]), + "each: could not set element 0" + ) + }) }) describe("modifyAll", () => { @@ -361,6 +514,17 @@ Expected a value greater than 0, got -1.1` { a: 0, b: 2, c: 0 } ) }) + + it("returns the original source when the traversal fails", () => { + type S = { readonly values?: ReadonlyArray } + const optic: Optic.Traversal = Optic.makeOptional( + (s) => s.values === undefined ? Result.fail("missing values") : Result.succeed(s.values), + (values, s) => s.values === undefined ? Result.fail("missing values") : Result.succeed({ ...s, values }) + ) + const source: S = {} + + strictEqual(optic.modifyAll(addOne)(source), source) + }) }) it("notUndefined", () => { @@ -382,6 +546,15 @@ Expected a value greater than 0, got -1.1` deepStrictEqual(getAll({ a: [1, -2, 3] }), [1, 3]) }) + it("getAll returns an empty array when the traversal fails", () => { + const optic: Optic.Traversal = Optic.makeOptional>( + () => Result.fail("cannot focus"), + () => Result.fail("cannot replace") + ) + + deepStrictEqual(Optic.getAll(optic)(1), []) + }) + it("replace copies only objects and arrays along the focused path", () => { type Task = { id: number; done: boolean; title: string } type Project = { id: number; name: string; tasks: Array } @@ -427,12 +600,14 @@ Expected a value greater than 0, got -1.1` const optic = Optic.id>().compose(Optic.some()) assertSuccess(optic.getResult(Option.some(1)), 1) assertFailure(optic.getResult(Option.none()), `Expected a Some value, got none()`) + deepStrictEqual(optic.set(2), Option.some(2)) }) it("none", () => { const optic = Optic.id>().compose(Optic.none()) assertSuccess(optic.getResult(Option.none()), undefined) assertFailure(optic.getResult(Option.some(1)), `Expected a None value, got some(1)`) + deepStrictEqual(optic.set(undefined), Option.none()) }) }) @@ -441,12 +616,14 @@ Expected a value greater than 0, got -1.1` const optic = Optic.id>().compose(Optic.success()) assertSuccess(optic.getResult(Result.succeed(1)), 1) assertFailure(optic.getResult(Result.fail("error")), `Expected a Result.Success value, got failure("error")`) + deepStrictEqual(optic.set(2), Result.succeed(2)) }) it("failure", () => { const optic = Optic.id>().compose(Optic.failure()) assertSuccess(optic.getResult(Result.fail("error")), "error") assertFailure(optic.getResult(Result.succeed(1)), `Expected a Result.Failure value, got success(1)`) + deepStrictEqual(optic.set("new error"), Result.fail("new error")) }) }) }) diff --git a/packages/effect/typetest/Optic.tst.ts b/packages/effect/typetest/Optic.tst.ts index af61413df18..ac283cbdf1e 100644 --- a/packages/effect/typetest/Optic.tst.ts +++ b/packages/effect/typetest/Optic.tst.ts @@ -1,8 +1,44 @@ -import { Optic, Schema } from "effect" -import type { Option, Result } from "effect" +import { Optic, Result, Schema } from "effect" +import type { Option } from "effect" import { describe, expect, it } from "tstyche" describe("Optic", () => { + describe("compose", () => { + const iso = Optic.makeIso((n) => n, (n) => n) + const lens = Optic.makeLens((n) => n, (n) => n) + const prism = Optic.makePrism(Result.succeed, (n) => n) + const optional = Optic.makeOptional(Result.succeed, (n) => Result.succeed(n)) + + it("preserves the optic kind matrix", () => { + expect(iso.compose(iso)).type.toBe>() + expect(iso.compose(lens)).type.toBe>() + expect(iso.compose(prism)).type.toBe>() + expect(iso.compose(optional)).type.toBe>() + + expect(lens.compose(iso)).type.toBe>() + expect(lens.compose(lens)).type.toBe>() + expect(lens.compose(prism)).type.toBe>() + expect(lens.compose(optional)).type.toBe>() + + expect(prism.compose(iso)).type.toBe>() + expect(prism.compose(lens)).type.toBe>() + expect(prism.compose(prism)).type.toBe>() + expect(prism.compose(optional)).type.toBe>() + + expect(optional.compose(iso)).type.toBe>() + expect(optional.compose(lens)).type.toBe>() + expect(optional.compose(prism)).type.toBe>() + expect(optional.compose(optional)).type.toBe>() + }) + + it("does not expose internal nodes", () => { + expect(iso).type.not.toHaveProperty("node") + expect(lens).type.not.toHaveProperty("node") + expect(prism).type.not.toHaveProperty("node") + expect(optional).type.not.toHaveProperty("node") + }) + }) + describe("key", () => { it("should not be allowed on union types", () => { type S = { readonly _tag: "A"; readonly a?: string } | { readonly _tag: "B"; readonly a?: number } @@ -94,9 +130,17 @@ describe("Optic", () => { }) }) - it("notUndefined", () => { - const optic = Optic.id().notUndefined() - expect(optic).type.toBe>() + describe("notUndefined", () => { + it("Prism", () => { + const optic = Optic.id().notUndefined() + expect(optic).type.toBe>() + }) + + it("Optional", () => { + type S = Record + const optic = Optic.id().at("a").notUndefined() + expect(optic).type.toBe>() + }) }) it("fromChecks", () => {