diff --git a/.changeset/twenty-olives-sit.md b/.changeset/twenty-olives-sit.md new file mode 100644 index 00000000..c72c2260 --- /dev/null +++ b/.changeset/twenty-olives-sit.md @@ -0,0 +1,5 @@ +--- +"@traversable/zod": patch +--- + +docs(zod): adds docs for `zx.makeLens` diff --git a/packages/zod/README.md b/packages/zod/README.md index 9ca780f2..d4126387 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -3,7 +3,7 @@

@traversable/zod or zx is an expansion pack for zod.

- +

The primary abstraction that powers zx is an obscure, if surprisingly useful idea from category theory called recursion schemes (and don't worry -- I promise you don't need any math to use zx 😌).

@@ -120,7 +120,7 @@ const MySchema = z.object({ b: z.object({ c: z.string(), d: z.array(z.boolean()) - }) + }) }) const defaultOne = zx.defaultValue(MySchema) @@ -233,91 +233,293 @@ console.log( #### `zx.makeLens` -- Example +`zx.makeLens` accepts a zod schema (classic, v4) as its first argument, and a +"selector function" as its second argument. + +Use the selector function to build up a lens via a series of property accesses. + +Let's look at a few examples to make things more concrete. + +##### Example #1: Lens + +For our first example, let's create a lens that focuses on a structure's `"a[0]"` path: ```typescript import { z } from 'zod/v4' import { zx } from "@traversable/zod" -//////////////////// -/// example #1 /// -//////////////////// +////////////////////////// +/// example #1: Lens /// +////////////////////////// -let schema_01 = z.tuple([z.string(), z.bigint()]) -let lens_01 = zx.makeLens(schema_01) +const Schema = z.object({ a: z.tuple([z.string(), z.bigint()]) }) -lens_01 -// ^? let lens_01: zx.SchemaLens<[string, bigint], string> +// Use autocompletion to "select" what you want to focus: +// ↆↆↆↆↆↆ +const Lens = zx.makeLens(Schema, $ => $.a[0]) -let get_01 = lens_01.get(['', 0n]) -// ^? let get_01: string -console.log(get_01) // => '' +Lens +// ^? const Lens: zx.Lens<{ a: [string, bigint] }, string> +// π™˜___________________π™˜ π™˜____π™˜ +// structure focus -let set_01 = lens_01.set('hey', ['', 0n]) -// ^? let set_01: [string, bigint] -console.log(set_01) // => ['hey', 0n] +// Lenses have 3 properties: -let modify_01 = lens_01.modify((s) => string.length > 0, ['', 0n]) -// ^? let modify_01: [boolean, bigint] -console.log(modify_01) // => [false, 0n] +/////////////// +// #1: +// Lens.get -- Given a structure, +// returns the focus +const ex_01 = Lens.get({ a: ['hi', 0n] }) +// π™˜_____________π™˜ +// structure -//////////////////// -/// example #2 /// -//////////////////// +console.log(ex_01) // => "hi" +// π™˜π™˜ +// focus -let schema_02 = z.union([ - z.object({ - tag: z.literal('ONE'), - ghi: z.number(), - }), - z.object({ - tag: z.literal('TWO'), - jkl: z.boolean(), - }) + +/////////////// +// #2: +// Lens.set -- Given a new focus and a structure, +// sets the new focus & returns the structure + +const ex_02 = Lens.set(`hey, ho, let's go`, { a: ['', 0n] }) +// π™˜_______________π™˜ π™˜___________π™˜ +// new focus structure + +console.log(ex_02) // => { a: ["hey, ho, let's go", 0n] } +// π™˜_______________π™˜ +// new focus + + +///////////////// +// #3: +// Lens.modify -- Given a "modify" callback and a structure, +// applies the callback to the focus & returns the structure + +const ex_03 = Lens.modify((str) => str.toUpperCase(), { a: [`hey, ho`, 0n] }) +// π™˜_______________________π™˜ π™˜__________________π™˜ +// callback structure + +console.log(ex_03) // => { a: ["HEY, HO", 0n] } +// π™˜_____π™˜ +// new focus + +// Note that if your callback changes the focus type, +// that will be reflected in the return type as well: + +const ex_04 = Lens.modify((str) => str.length > 0, { a: ['', 0n] }) +// π™˜____________________π™˜ π™˜___________π™˜ +// callback structure + +console.log(ex_04) // => { a: [false, 0n] } +// ^? const ex_04: { a: [boolean, bigint] } +// π™˜_____π™˜ +// new focus +``` + +##### Example #2: Prism + +When you use `zx.makeLens` on a __union type__, you get back a different kind +of lens called a __prism__. + +Let's see how prisms differ from lenses: + +```typescript +import { z } from 'zod/v4' +import { zx } from "@traversable/zod" + +/////////////////////////// +/// example #2: Prism /// +/////////////////////////// + +const Schema = z.union([ + z.object({ tag: z.literal('ONE'), ghi: z.number() }), + z.object({ tag: z.literal('TWO') }) ]) -let lens_02 = zx.makeLens( - schema, - $ => $.κ–›ONE.ghi -) +// Let's focus on the first union member's "ghi" property. + +// If a discriminant can be inferred, autocompletion allows +// you to select that member by its discriminant, +// prefixed by `κ–›`: +// +// ↆↆↆↆↆ +const Prism = zx.makeLens(Schema, $ => $.κ–›ONE.ghi) + +Prism +// ^? Prism: zx.Prism<{ tag: "ONE", ghi: number } | { tag: "TWO" }, number | undefined> +// π™˜________________________________________π™˜ π™˜________________π™˜ +// structure focus + +// Prisms have the same 3 properties as lenses, +// but they behave like **pattern matchers** +// instead of _property accessors_ + +/////////////// +// #1: +// Prism.get -- Given a matching structure, +// returns the focus + +const ex_01 = Prism.get({ tag: 'ONE', ghi: 123 }) +// π™˜____________________π™˜ +// structure + +console.log(ex_01) // => 123 +// π™˜π™˜π™˜ +// focus + +// Prism.get -- If the match fails, +// returns undefined + +const ex_02 = Prism.get({ tag: 'TWO' }) +// π™˜___________π™˜ +// structure + +console.log(ex_02) // => undefined +// π™˜π™˜π™˜ +// no match + + +/////////////// +// #2: +// Prism.set -- Given a new focus and a matching structure, +// sets the new focus & returns the structure + +const ex_03 = Prism.set(9_000, { tag: 'ONE', ghi: 123 }) +// π™˜___π™˜ π™˜____________________π™˜ +// new focus structure -lens_02 -// ^? let lens_02: zx.SchemaLens< -// | { tag: "ONE", ghi: number } -// | { tag: "TWO", jkl: boolean }, -// number | undefined -// > - -let get_02A = lens_02.get({ tag: 'ONE', ghi: 0 }) -// ^? let get_02A: number | undefined -console.log(get_02A) // => 0 - -let get_02B = lens_02.get({ tag: 'TWO', jkl: true }) -// ^? let get_02B: number | undefined -console.log(get_02B) // => undefined - -let set_02A = lens_02.set(9000, { tag: 'ONE', ghi: 0 }) -// ^? let set_02A: { tag: "ONE", ghi: number } | { tag: "TWO", jkl: boolean } -console.log(set_02A) // => { tag: 'ONE', ghi: 9000 } - -let set_02B = lens_02.set(9000, { tag: 'TWO', jkl: true }) -// ^? let set_02B: { tag: "ONE", ghi: number } | { tag: "TWO", jkl: boolean } -console.log(set_02B) // => { tag: 'TWO', jkl: true } - -let modify_02A = lens_02.modify((n) => [n, n] as const, { tag: 'ONE', ghi: 0 }) -// ^? let modify_02A: -// | { tag: "ONE", ghi: readonly [number, number] } -// | { tag: "TWO", jkl: boolean } -console.log(modify_02A) // => { tag: 'ONE', ghi: [0, 0] } - -let modify_02B = lens_02.modify((n) => [n, n] as const, { tag: 'TWO', jkl: true }) -// ^? let modify_02B: -// | { tag: "ONE", ghi: readonly [number, number] } -// | { tag: "TWO", jkl: boolean } -console.log(modify_02B) // => { tag: 'TWO', jkl: true } +console.log(ex_03) // => { tag: 'ONE', ghi: 9000 } +// π™˜__π™˜ +// new focus + +// Prism.set -- If the match fails, +// returns the structure unchanged + +const ex_04 = Prism.set(9000, { tag: 'TWO' }) + +console.log(ex_04) // => { tag: 'TWO' } +// π™˜__________π™˜ +// no match + + +////////////////// +// #3: +// Prism.modify -- Given a "modify" callback and a matching structure, +// applies the callback to the focus & returns the structure + +// Just like with lenses, if your callback changes the focus type, +// that will be reflected in the return type: + +const ex_05 = Prism.modify((n) => [n, n], { tag: 'ONE', ghi: 123 }) +// π™˜___________π™˜ π™˜____________________π™˜ +// callback structure + +console.log(ex_05) // => { tag: 'ONE', ghi: [123, 123] } +// ^? const ex_05: { tag: "ONE", ghi: number[] } | { tag: "TWO" } + +// Prism.modify -- If the match fails, +// returns the structure unchanged + +const ex_06 = Prism.modify((n) => n + 1, { tag: 'TWO' }) +// π™˜__________π™˜ π™˜___________π™˜ +// callback structure + +console.log(ex_06) // => { tag: 'TWO' } +// ^? const ex_06: { tag: "ONE", ghi: number } | { tag: "TWO" } +``` + +##### Example #3: Traversal + +When you use `zx.makeLens` on a __collection type__ (such as `z.array` or `z.record`), +you get back a different kind of lens called a __traversal__. + +Let's see how traversals differ from lenses and prisms: + +```typescript +import { z } from 'zod/v4' +import { zx } from "@traversable/zod" + +/////////////////////////////// +/// example #3: Traversal /// +/////////////////////////////// + +const Schema = z.object({ + a: z.array( + z.object({ + b: z.number(), + c: z.string() + }) + ) +}) + +// Let's focus on the `"b"` property of each of the elements of the structure's `"a"` property: + +// To indicate that you want to traverse the array, +// autocomplete the `α£”κ“Έκ“Έ` field: +// ↆↆ +const Traversal = zx.makeLens(Schema, $ => $ => $.a.α£”κ“Έκ“Έ.b) + + +Traversal +// ^? Traversal: zx.Traversal<{ a: { b: number, c: string }[] }, number> +// π™˜_____________________________π™˜ π™˜____π™˜ +// structure focus + +// Traversals have the same 3 properties as lenses and prisms, +// but they behave like **for-of loops** +// instead of _property accessors_ or _patterns matchers_ + + +/////////////// +// #1: +// Traversal.get -- Given a matching structure, +// returns all of the focuses + +const ex_01 = Traversal.get({ a: [{ b: 0, c: '' }, { b: 1, c: '' }] }) +// π™˜_____________________________________π™˜ +// structure + +console.log(ex_01) // => [0, 1] +// π™˜__π™˜ +// focus + + +/////////////// +// #2: +// Traversal.set -- Given a new focus and a matching structure, sets all of the elements +// of the collection to the new focus & returns the structure + +const ex_02 = Traversal.set(9_000, { a: [{ b: 0, c: '' }, { b: 1, c: '' }] }) +// π™˜___π™˜ π™˜_____________________________________π™˜ +// new focus structure + +console.log(ex_02) // => { a: [{ b: 9000, c: '' }, { b: 9000, c: '' }] } +// π™˜__π™˜ π™˜__π™˜ +// new focus new focus + + +////////////////// +// #3: +// Traversal.modify -- Given a "modify" callback and a matching structure, +// applies the callback to _each_ focus & returns the structure + +// Just like with lenses & prisms, if your callback changes the focus type, +// that will be reflected in the return type: + +const ex_03 = Traversal.modify((n) => [n, n + 1], { a: [{ b: 0, c: '' }, { b: 1, c: '' }] }) +// π™˜______________π™˜ π™˜_____________________________________π™˜ +// callback structure + +console.log(ex_03) // => { a: [{ b: [0, 1], c: '' }, { b: [1, 2], c: '' }] } +// ^? const ex_03: { a: { b: number[], c: string }[] } +// π™˜______π™˜ +// new focus ``` + ## Advanced Features ### Combinators diff --git a/packages/zod/src/lens.ts b/packages/zod/src/lens.ts index 15e35299..dc4a190d 100644 --- a/packages/zod/src/lens.ts +++ b/packages/zod/src/lens.ts @@ -1,7 +1,6 @@ -// import type { z } from 'zod/v4' import { z } from 'zod/v4' -import type { Force, Key, newtype, Showable } from '@traversable/registry' +import type { Key, newtype, Showable } from '@traversable/registry' import { Array_from, Array_isArray, @@ -97,9 +96,6 @@ declare namespace Z { = { _zod: { def: { type: unknown } } } > { _zod: { def: { type: T['_zod']['def']['type'] } }, _output: unknown } // - /** - * TODO: see if `z.set`'s valueType has been added to `_zod.def.valueType` in zod v3.25.0 - */ interface Set { _zod: { def: { type: 'set' } }, _output: unknown } interface Enum< T extends @@ -312,7 +308,7 @@ const Invariant = { TaggedSchemaNotFound(Cmd) { throw Error('8' + String(Cmd)) }, PropertyNotFound(Cmd) { throw Error('9' + String(Cmd)) }, IndexNotFound(Cmd) { throw Error('10' + String(Cmd)) }, -} satisfies Record never> +} satisfies globalThis.Record never> const previousOpticWasLens = (acc: Profunctor.Optic[]) => acc[acc.length - 1]?.[symbol.tag] === 'Lens' @@ -442,16 +438,16 @@ function interpreter(type: T, ..._path: (keyof any)[]) { // console.log() const tag = optic[symbol.tag] - const get = Get[tag](optic) as SchemaLens['get'] - const set = Set[tag](optic) as SchemaLens['set'] - const modify = Modify[tag](optic) as SchemaLens['modify'] + const get = Get[tag](optic) as Lens['get'] + const set = Set[tag](optic) as Lens['set'] + const modify = Modify[tag](optic) as Lens['modify'] return { get, set, modify, type: tag, - } // satisfies SchemaLens, Z.infer, []> + } // satisfies Lens, Z.infer, []> } // const optics = path.reduce( @@ -552,37 +548,33 @@ interface Proxy_optional { [DSL.chainOptional]: Proxy [DSL.coalesceOptional]: Proxy], KS> [symbol.type]: T - [symbol.path]: [...KS, symbol.optional] + [symbol.path]: KS } interface Proxy_array { [DSL.traverseArray]: Proxy [symbol.type]: T - [symbol.path]: [...KS, array: number] + [symbol.path]: KS // [symbol.path]: [...KS, symbol.array] } -type Proxy_record +type RecordType = S[0] extends { enum: { [x: string | number]: string | number } } - ? never | Proxy_finiteRecord - : never | Proxy_nonfiniteRecord + ? never | Proxy_finiteRecord + : never | Proxy_nonfiniteRecord interface Proxy_nonfiniteRecord { - [DSL.traverseRecord]: Proxy - // [DSL.traverseRecord]: Proxy + [DSL.traverseRecord]: Proxy [symbol.type]: T - [symbol.path]: [...KS, nonfiniteRecord: string] - // [symbol.path]: [...KS, string] + [symbol.path]: KS } interface Proxy_finiteRecord extends newtype< { [K in S[0]['enum'][keyof S[0]['enum']]]: Proxy } > { - [DSL.traverseRecord]: Proxy - // [DSL.traverseRecord]: Proxy + [DSL.traverseRecord]: Proxy [symbol.type]: T - [symbol.path]: [...KS, finiteRecord: string] - // [symbol.path]: [...KS, symbol.record] + [symbol.path]: KS } interface Proxy_primitive { @@ -596,7 +588,7 @@ interface Proxy_set { [symbol.path]: KS } -type Union< +type UnionType< T, S extends [any], KS extends (keyof any)[], @@ -604,25 +596,26 @@ type Union< Disc extends keyof any = keyof _[number]['_zod']['def']['shape'], Tag extends keyof any = _[number]['_zod']['def']['shape'][Disc]['_output'] > = [Disc] extends [never] - ? never | Proxy_union + ? never | Proxy_union : never | Disjoint< Tag extends Tag ? [ Tag, Extract<_[number]['_zod']['def']['shape'], Record>, - Extract> + Extract>, + Disc ] : never, - KS + [...KS, disjoint: symbol.disjoint] > -interface Proxy_union extends newtype< +interface Proxy_union extends newtype< { [I in Extract as `${GlobalDSL['unionPrefix']}${I}`]: Proxy } -> { [symbol.type]: T } +> { [symbol.type]: T, [symbol.path]: KS } -type Disjoint = never | Proxy_disjointUnion< +type Disjoint = never | Proxy_disjointUnion< U[2], - { [M in U as `${GlobalDSL['unionPrefix']}${Key}`]: Proxy_object<[M[2], { [K in keyof M[1]]: M[1][K] }], KS> }, + { [M in U as `${GlobalDSL['unionPrefix']}${Key}`]: Proxy_object<[M[2], { [K in keyof M[1]]: M[1][K] }], [...KS, U[3], M[0]]> }, KS > @@ -633,15 +626,31 @@ interface Proxy_disjointUnion extends type Proxy = [S] extends [Z.Object] ? Proxy_object<[T[0]['_output'], S['_zod']['def']['shape']], KS> - : [S] extends [Z.Optional] ? Proxy_optional + : [S] extends [Z.Optional] ? Proxy_optional : [S] extends [Z.Tuple] ? Proxy_tuple - : [S] extends [Z.Union] ? Union - : [S] extends [Z.Array] ? Proxy_array - : [S] extends [Z.Record] ? Proxy_record - : [S] extends [Z.Set] ? Proxy_set ? V : never, KS> + : [S] extends [Z.Union] ? UnionType + : [S] extends [Z.Array] ? Proxy_array + : [S] extends [Z.Record] ? RecordType + : [S] extends [Z.Set] ? Proxy_set ? V : never, [...KS, symbol.set]> : Proxy_primitive -export interface SchemaLens { +type MakeLens + = symbol.array extends KS[number] ? Traversal + : symbol.record extends KS[number] ? Traversal + : symbol.union extends KS[number] ? Prism + : symbol.disjoint extends KS[number] ? Prism + : Lens + +export interface Traversal { + get(data: S): T + set(focus: A, data: S): S + set(focus: A): (data: S) => S + modify(fn: (focus: A) => B, source: S): Modify + modify(fn: (focus: A) => B): (source: S) => Modify + type: Profunctor.Optic.Type +} + +export interface Lens { get(data: S): A set(focus: A, data: S): S set(focus: A): (data: S) => S @@ -650,6 +659,16 @@ export interface SchemaLens { type: Profunctor.Optic.Type } +export interface Prism { + get(data: S): A + set(focus: A, data: S): S + set(focus: A): (data: S) => S + modify(fn: (focus: A) => B, source: S): Modify + modify(fn: (focus: A) => B): (source: S) => Modify + type: Profunctor.Optic.Type +} + + const areAllObjects = (x: unknown[]): x is Z.Object[] => x.every(tagged('object')) function isDisjointUnionSchema(CURSOR: Z.Union) { @@ -974,13 +993,13 @@ export function parsePath_(type: T, ...path: (keyof any)[]) export function makeLens< Type extends z.ZodType, - Proxy extends Proxy.new, + $ extends Proxy.new, Target, >( type: Type, - selector: (proxy: Proxy) => Target + selector: ($: $) => Target // @ts-ignore -): SchemaLens, Z.infer, Target[symbol.path]> +): MakeLens, Target[symbol.type], Target[symbol.path]> export function makeLens(type: Type, selector: Witness) { const { proxy, revoke } = createProxy(type) @@ -1002,29 +1021,48 @@ export declare namespace Proxy { Proxy_object as object, Proxy_optional as optional, Proxy_primitive as primitive, - Proxy_record as record, + RecordType as record, Proxy_tuple as tuple, Proxy_union as union, } } +type Drop2 = S extends [any, any, ...infer T] ? T : never -export type Omit_ - = never | [Exclude] extends [never] - ? unknown - : { [P in keyof T as P extends K ? never : P]: T[P] } - -/** - * TODO: - * Make this not break with arrays - */ -export type Modify - = KS extends [infer K extends keyof T] ? Omit_ & { [P in K]: V } - : KS extends [infer K, ...infer Todo] - ? K extends keyof T ? Omit_ & { [P in K]: Modify } - : T +export type Modify + = KS extends [infer K, ...infer Todo] + ? symbol.disjoint extends K ? + | Exclude + | (Drop2 extends infer Next ? Next extends [] ? V : Modify, V> : never) + : symbol.array extends K ? Modify + : symbol.record extends K ? Modify + : { [P in keyof T]: P extends K ? Modify : T[P] } : T +// export type Modify +// = KS extends [] ? V +// : KS extends [infer K, ...infer Todo] +// ? symbol.disjoint extends K ? +// | Exclude +// | Modify, Extract, V> +// : symbol.array extends K ? Modify +// : { [P in keyof T]: P extends K ? Modify : T[P] } +// : T + +// export type Modify +// = KS extends [] ? V +// : KS extends [infer K, ...infer Todo] +// ? symbol.disjoint extends K ? +// | Exclude +// | Modify, Extract, V> +// : { [P in keyof T]: P extends K ? Modify : T[P] } +// : T + +// export type Modify +// = KS extends [infer K] ? { [P in keyof T]: P extends K ? V : T[P] } +// : KS extends [infer K, ...infer Todo] +// ? { [P in keyof T]: P extends K ? Modify : T[P] } +// : T // function coalesceFallback(fallback: unknown, ...path: (keyof any)[]) { // let Cmd: keyof any | undefined diff --git a/packages/zod/test/lens.test.ts b/packages/zod/test/lens.test.ts index 4970de9d..7f816e86 100644 --- a/packages/zod/test/lens.test.ts +++ b/packages/zod/test/lens.test.ts @@ -216,6 +216,26 @@ const BIG_SCHEMA = z.object({ vi.describe('〖⛳️〗‹‹‹ ❲@traversable/zod❳: zx.makeLens', () => { vi.it('temp 1', () => { + const schema_00 = z.object({ a: z.object({ b: z.number() }) }) + const lens_00 = zx.makeLens(schema_00, $ => $.a.b) + const ex_00 = lens_00.modify($ => [$, $] satisfies [any, any], { a: { b: 1 } }) + // ^? + vi.assertType<{ a: { b: [number, number] } }>(ex_00) + + const schema_01 = z.object({ a: z.array(z.object({ b: z.number(), c: z.string() })) }) + const lens_01 = zx.makeLens(schema_01, $ => $.a.α£”κ“Έκ“Έ) + const ex_01 = lens_01.modify($ => [$.b, $.c] satisfies [any, any], { a: [] }) + // ^? + vi.assertType<{ a: [number, string][] }>(ex_01) + + const schema_02 = z.union([z.object({ tag: z.literal('ONE') }), z.object({ tag: z.literal('TWO') })]) + const lens_02 = zx.makeLens(schema_02, $ => $.κ–›ONE) + + // const ex_02 = lens_02.modify($ => ({ tag: [$.tag] }), { tag: 'ONE' }) + const ex_02 = lens_02.modify($ => ({ tag: [$.tag] }), { tag: 'ONE' }) + // ^? + vi.assertType<{ tag: 'ONE'[] } | { tag: 'TWO' }>(ex_02) + const LENS_040 = zx.makeLens( BIG_SCHEMA, (proxy) => proxy.A.Ηƒ.κ–›2.x