@playfast/reform-forms 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Values, validation, field metadata, list operations, submit flow, and correlatio
13
13
 
14
14
  ---
15
15
 
16
- `@playfast/reform-forms` owns everything a form needs *except* its widgets. It has no DOM and no React API — it produces a typed, platform-neutral view that any renderer can map. Pair it with [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react) for typed JSX, or consume the view directly from a custom host.
16
+ `@playfast/reform-forms` owns everything a form needs _except_ its widgets. It has no DOM and no React API — it produces a typed, platform-neutral view that any renderer can map. Pair it with [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react) for typed JSX, or consume the view directly from a custom host.
17
17
 
18
18
  ## Install
19
19
 
@@ -61,9 +61,7 @@ const CheckoutFormLive = Form.live(CheckoutForm, {
61
61
  items: { minItems: 1, maxItems: inputs.settings.maxItems },
62
62
  }),
63
63
  validate: ({ values }) =>
64
- values.email.includes('@')
65
- ? undefined
66
- : Form.error('email', 'Email must contain @'),
64
+ values.email.includes('@') ? undefined : Form.error('email', 'Email must contain @'),
67
65
  submit: ({ decoded, inputs }) => saveCheckout(decoded, inputs.user.id),
68
66
  })
69
67
  ```
@@ -71,7 +69,7 @@ const CheckoutFormLive = Form.live(CheckoutForm, {
71
69
  Read the form inside compositions with `Form.view(CheckoutForm)` — a typed, platform-neutral object:
72
70
 
73
71
  ```ts
74
- const form = yield* Form.view(CheckoutForm)
72
+ const form = yield * Form.view(CheckoutForm)
75
73
 
76
74
  form.field('email').set('a@example.com')
77
75
  form.array('items').append({ name: 'Milk', qty: 1 })
package/package.json CHANGED
@@ -1,37 +1,39 @@
1
1
  {
2
2
  "name": "@playfast/reform-forms",
3
- "playbook": "./playbook",
4
- "version": "1.0.1",
5
- "type": "module",
3
+ "version": "1.2.0",
6
4
  "description": "Headless, schema-driven form state for reform — values, validation, field limitations, list operations, and submit flow, rendering nothing.",
7
5
  "keywords": [
8
- "reform",
9
6
  "effect",
10
- "forms",
11
7
  "form-state",
12
- "validation",
8
+ "forms",
9
+ "headless",
10
+ "reform",
13
11
  "schema",
14
- "headless"
12
+ "validation"
15
13
  ],
14
+ "bugs": {
15
+ "url": "https://github.com/playfast/reform/issues"
16
+ },
16
17
  "license": "MIT",
17
18
  "repository": {
18
19
  "type": "git",
19
20
  "url": "https://github.com/playfast/reform.git",
20
21
  "directory": "packages/forms"
21
22
  },
22
- "bugs": {
23
- "url": "https://github.com/playfast/reform/issues"
24
- },
23
+ "files": [
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "type": "module",
25
28
  "sideEffects": false,
26
29
  "exports": {
27
30
  "./package.json": "./package.json",
28
31
  ".": "./src/index.ts",
29
32
  "./*": "./src/*.ts"
30
33
  },
31
- "files": [
32
- "src",
33
- "README.md"
34
- ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
35
37
  "scripts": {
36
38
  "clean": "rm -rf dist .tsbuildinfo",
37
39
  "check": "tsc --noEmit",
@@ -43,10 +45,8 @@
43
45
  "lint:fix": "oxlint --fix src"
44
46
  },
45
47
  "peerDependencies": {
46
- "effect": "*",
47
- "@playfast/reform": "*"
48
+ "@playfast/reform": "*",
49
+ "effect": "*"
48
50
  },
49
- "publishConfig": {
50
- "access": "public"
51
- }
51
+ "playbook": "./playbook"
52
52
  }
package/src/form.test.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { expect, test } from 'vitest'
2
- import { Effect, Layer, ManagedRuntime, Option, Schema as S } from 'effect'
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Layer, Option, Schema as S } from 'effect'
3
3
  import { Engine } from '@playfast/reform'
4
4
  import * as Form from './form'
5
5
 
@@ -23,7 +23,9 @@ const CheckoutSchema = S.Struct({
23
23
  ),
24
24
  })
25
25
 
26
- class CheckoutForm extends Form.make('CheckoutForm', { schema: CheckoutSchema }) {}
26
+ class CheckoutForm extends Form.make('CheckoutForm', {
27
+ schema: CheckoutSchema,
28
+ }) {}
27
29
 
28
30
  const layer = Form.live(CheckoutForm, {
29
31
  initial: {
@@ -35,58 +37,40 @@ const layer = Form.live(CheckoutForm, {
35
37
  values.email.includes('@') ? undefined : Form.error('email', 'Email must contain @'),
36
38
  }).pipe(Layer.provideMerge(Engine))
37
39
 
38
- test('field bindings write through the form reducer', async () => {
39
- const runtime = ManagedRuntime.make(layer)
40
- try {
41
- await runtime.runPromise(
42
- Effect.gen(function* () {
43
- const view = yield* Form.view(CheckoutForm)
44
- view.field('email').set('a@example.com')
40
+ it.scopedLive('field bindings write through the form reducer', () =>
41
+ Effect.gen(function* () {
42
+ const view = yield* Form.view(CheckoutForm)
43
+ view.field('email').set('a@example.com')
45
44
 
46
- const next = yield* waitUntil(Form.view(CheckoutForm), (v) => v.values.email === 'a@example.com')
47
- expect(next.field('email').dirty).toBe(true)
48
- }),
45
+ const next = yield* waitUntil(
46
+ Form.view(CheckoutForm),
47
+ (v) => v.values.email === 'a@example.com',
49
48
  )
50
- } finally {
51
- await runtime.dispose()
52
- }
53
- })
49
+ expect(next.field('email').dirty).toBe(true)
50
+ }).pipe(Effect.provide(layer)),
51
+ )
54
52
 
55
- test('array bindings append and remove items', async () => {
56
- const runtime = ManagedRuntime.make(layer)
57
- try {
58
- await runtime.runPromise(
59
- Effect.gen(function* () {
60
- const view = yield* Form.view(CheckoutForm)
61
- view.array('items').append({ name: 'milk', qty: 1 })
53
+ it.scopedLive('array bindings append and remove items', () =>
54
+ Effect.gen(function* () {
55
+ const view = yield* Form.view(CheckoutForm)
56
+ view.array('items').append({ name: 'milk', qty: 1 })
62
57
 
63
- const withItem = yield* waitUntil(Form.view(CheckoutForm), (v) => v.values.items.length === 1)
64
- expect(withItem.array('items').items[0]?.key).toMatch(/^form-item-/)
58
+ const withItem = yield* waitUntil(Form.view(CheckoutForm), (v) => v.values.items.length === 1)
59
+ expect(withItem.array('items').items[0]?.key).toMatch(/^form-item-/)
65
60
 
66
- withItem.array('items').remove(0)
67
- const empty = yield* waitUntil(Form.view(CheckoutForm), (v) => v.values.items.length === 0)
68
- expect(empty.values.items).toEqual([])
69
- }),
70
- )
71
- } finally {
72
- await runtime.dispose()
73
- }
74
- })
61
+ withItem.array('items').remove(0)
62
+ const empty = yield* waitUntil(Form.view(CheckoutForm), (v) => v.values.items.length === 0)
63
+ expect(empty.values.items).toEqual([])
64
+ }).pipe(Effect.provide(layer)),
65
+ )
75
66
 
76
- test('submit routes custom validation errors to fields', async () => {
77
- const runtime = ManagedRuntime.make(layer)
78
- try {
79
- await runtime.runPromise(
80
- Effect.gen(function* () {
81
- const view = yield* Form.view(CheckoutForm)
82
- view.submit()
67
+ it.scopedLive('submit routes custom validation errors to fields', () =>
68
+ Effect.gen(function* () {
69
+ const view = yield* Form.view(CheckoutForm)
70
+ view.submit()
83
71
 
84
- const invalid = yield* waitUntil(Form.view(CheckoutForm), (v) => v.validationCount > 0)
85
- const error = invalid.field('email').error
86
- expect(Option.isSome(error) ? error.value : undefined).toBe('Email must contain @')
87
- }),
88
- )
89
- } finally {
90
- await runtime.dispose()
91
- }
92
- })
72
+ const invalid = yield* waitUntil(Form.view(CheckoutForm), (v) => v.validationCount > 0)
73
+ const error = invalid.field('email').error
74
+ expect(Option.isSome(error) ? error.value : undefined).toBe('Email must contain @')
75
+ }).pipe(Effect.provide(layer)),
76
+ )
package/src/form.ts CHANGED
@@ -9,17 +9,17 @@ import {
9
9
  Runtime,
10
10
  Schema as S,
11
11
  } from 'effect'
12
+ import { Event, Reducer, State, type Trigger } from '@playfast/reform'
12
13
  import {
14
+ type AnySource,
13
15
  Bus,
14
16
  CurrentTracker,
15
- Event,
16
- Reducer,
17
17
  Reducers,
18
- State,
18
+ type SourceIdentifier,
19
+ type SourceName,
20
+ type SourceValue as ReformSourceValue,
19
21
  type Store,
20
- type Trigger,
21
- } from '@playfast/reform'
22
- import type { AnySource, Source } from '@playfast/reform'
22
+ } from '@playfast/reform/internal'
23
23
  import {
24
24
  getNestedValue,
25
25
  isPathOrParentDirty,
@@ -46,9 +46,6 @@ import {
46
46
 
47
47
  export { error }
48
48
 
49
- // Plain-JSON UI-contract shape (flows into FieldBinding.limitations, serialized
50
- // over the wire): optional knobs are part of the external contract, so the
51
- // `ExternalApi` postfix opts this out of the Option-only field rule.
52
49
  export interface FieldLimitationsExternalApi<A = unknown> {
53
50
  readonly required?: boolean
54
51
  readonly disabled?: boolean
@@ -129,33 +126,25 @@ export interface FormView<Values, Inputs = {}> {
129
126
 
130
127
  type InputRecord = Readonly<Record<string, AnySource>>
131
128
 
132
- // Extracted structurally (`S['name']`) rather than through a `Source<infer N,
133
- // unknown>` conditional: `Source`'s value parameter is invariant (`in out A`),
134
- // so a concrete `StateToken<'x', V>` never matches `Source<_, unknown>` and the
135
- // conditional would erase every input's key to `never`.
136
- type SourceName<S extends AnySource> = S['name']
137
- type SourceValue<S> = S extends Source<string, infer A> ? A : never
138
-
139
129
  type InputsObject<Inputs extends InputRecord> = {
140
- readonly [K in keyof Inputs as SourceName<Inputs[K]>]: SourceValue<Inputs[K]>
130
+ readonly [K in keyof Inputs as SourceName<Inputs[K]>]: ReformSourceValue<Inputs[K]>
141
131
  }
142
132
 
143
133
  type InputStores<Inputs extends InputRecord> = {
144
- [K in keyof Inputs]: Inputs[K] extends { readonly store: Context.Tag<infer Service, any> }
134
+ [K in keyof Inputs]: Inputs[K] extends {
135
+ readonly store: Context.Tag<infer Service, infer _Store>
136
+ }
145
137
  ? Service
146
138
  : never
147
139
  }[keyof Inputs]
148
140
 
149
- type ValuesOfSchema<S extends S.Schema.Any> = S.Schema.Encoded<S>
150
- type DecodedOfSchema<S extends S.Schema.Any> = S.Schema.Type<S>
141
+ type ValuesOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Encoded<Schema>
142
+ type DecodedOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Type<Schema>
151
143
 
152
144
  export interface RuntimeConfig<Values, Decoded, Inputs> {
153
145
  readonly initial: Values
154
146
  // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob (not a serializable shape); Option would break external form definitions
155
- readonly limit?: (ctx: {
156
- readonly values: Values
157
- readonly inputs: Inputs
158
- }) => Limitations
147
+ readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
159
148
  readonly validate: (ctx: {
160
149
  readonly values: Values
161
150
  readonly decoded: Decoded
@@ -171,10 +160,7 @@ export interface RuntimeConfig<Values, Decoded, Inputs> {
171
160
  export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
172
161
  readonly initial: Values
173
162
  // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
174
- readonly limit?: (ctx: {
175
- readonly values: Values
176
- readonly inputs: Inputs
177
- }) => Limitations
163
+ readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
178
164
  // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
179
165
  readonly validate?: (ctx: {
180
166
  readonly values: Values
@@ -193,9 +179,22 @@ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
193
179
  }) => void | Effect.Effect<unknown, unknown, R>
194
180
  }
195
181
 
196
- export interface FormManifest<N extends string, Schema extends S.Schema.Any, Inputs extends InputRecord> {
182
+ export interface FormSchemaReflection {
183
+ readonly ast: S.Schema<unknown, unknown, unknown>['ast']
184
+ }
185
+
186
+ export interface FormManifestReflection<N extends string> {
197
187
  readonly kind: 'Form'
198
188
  readonly name: N
189
+ readonly schema: FormSchemaReflection
190
+ readonly inputs: InputRecord
191
+ }
192
+
193
+ export interface FormManifest<
194
+ N extends string,
195
+ Schema extends S.Schema.AnyNoContext,
196
+ Inputs extends InputRecord,
197
+ > extends FormManifestReflection<N> {
199
198
  readonly schema: Schema
200
199
  readonly inputs: Inputs
201
200
  }
@@ -209,17 +208,40 @@ interface FormEvents {
209
208
  readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
210
209
  readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
211
210
  readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
212
- readonly move: Event.EventClass<string, { readonly path: string; readonly from: number; readonly to: number }>
213
- readonly swap: Event.EventClass<string, { readonly path: string; readonly a: number; readonly b: number }>
211
+ readonly move: Event.EventClass<
212
+ string,
213
+ { readonly path: string; readonly from: number; readonly to: number }
214
+ >
215
+ readonly swap: Event.EventClass<
216
+ string,
217
+ { readonly path: string; readonly a: number; readonly b: number }
218
+ >
219
+ }
220
+
221
+ export interface FormVisitor<Result> {
222
+ readonly visit: <
223
+ N extends string,
224
+ Schema extends S.Schema.AnyNoContext,
225
+ Inputs extends InputRecord,
226
+ >(
227
+ form: FormClass<N, Schema, Inputs>,
228
+ ) => Result
229
+ }
230
+
231
+ export interface AnyForm {
232
+ new (): {}
233
+ readonly manifest: FormManifestReflection<string>
234
+ readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
214
235
  }
215
236
 
216
237
  export interface FormClass<
217
238
  N extends string,
218
- Schema extends S.Schema.Any,
239
+ Schema extends S.Schema.AnyNoContext,
219
240
  Inputs extends InputRecord,
220
241
  > {
221
242
  new (): {}
222
243
  readonly manifest: FormManifest<N, Schema, Inputs>
244
+ readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
223
245
  readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
224
246
  readonly config: Context.Tag<
225
247
  RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
@@ -232,27 +254,16 @@ export interface FormClass<
232
254
  >
233
255
  }
234
256
 
235
- // `any` (not `InputRecord`) in the inputs slot: the inputs type flows into the
236
- // invariant `config` tag (via `InputsObject`), so a wildcard there must be `any`
237
- // for a concrete form — whose inputs object has real keys — to remain assignable
238
- // to `AnyForm`. Same Schema-variance escape the schema slot already uses.
239
- export type AnyForm = FormClass<string, S.Schema.Any, any>
240
-
241
- export type Values<F extends AnyForm> = F extends FormClass<string, infer Schema, any>
242
- ? ValuesOfSchema<Schema>
243
- : never
257
+ export type Values<F> =
258
+ F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? ValuesOfSchema<Schema> : never
244
259
 
245
- export type Decoded<F extends AnyForm> = F extends FormClass<string, infer Schema, any>
246
- ? DecodedOfSchema<Schema>
247
- : never
260
+ export type Decoded<F> =
261
+ F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? DecodedOfSchema<Schema> : never
248
262
 
249
- export type Inputs<F extends AnyForm> = F extends FormClass<string, S.Schema.Any, infer Inputs>
250
- ? InputsObject<Inputs>
251
- : never
263
+ export type Inputs<F> =
264
+ F extends FormClass<infer _Name, infer _Schema, infer Inputs> ? InputsObject<Inputs> : never
252
265
 
253
- export type View<F extends AnyForm> = FormView<Values<F>, Inputs<F>>
254
-
255
- const unknownSchema: S.Schema<any, any> = Fn.unsafeCoerce(S.Unknown)
266
+ export type View<F> = FormView<Values<F>, Inputs<F>>
256
267
 
257
268
  const arrayKeyCounter = { current: 0 }
258
269
  const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
@@ -262,12 +273,15 @@ export const arrayKeysFor = (source: unknown, path = ''): Record<string, Readonl
262
273
  const visit = (node: KeyVisitNode): void => {
263
274
  if (Array.isArray(node.current)) {
264
275
  out[node.path] = node.current.map(() => makeArrayKey())
265
- node.current.forEach((element, index) => visit({ current: element, path: `${node.path}[${index}]` }))
276
+ node.current.forEach((element, index) =>
277
+ visit({ current: element, path: `${node.path}[${index}]` }),
278
+ )
266
279
  return
267
280
  }
268
281
  if (node.current !== null && typeof node.current === 'object') {
269
- Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(([key, child]) =>
270
- visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
282
+ Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
283
+ ([key, child]) =>
284
+ visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
271
285
  )
272
286
  }
273
287
  }
@@ -287,8 +301,10 @@ const initialState = <Values>(initial: Values): FormState<Values> => ({
287
301
  arrayKeys: arrayKeysFor(initial),
288
302
  })
289
303
 
290
- const markTouched = (touched: Readonly<Record<string, boolean>>, path: string): Readonly<Record<string, boolean>> =>
291
- ({ ...touched, [path]: true })
304
+ const markTouched = (
305
+ touched: Readonly<Record<string, boolean>>,
306
+ path: string,
307
+ ): Readonly<Record<string, boolean>> => ({ ...touched, [path]: true })
292
308
 
293
309
  interface ArrayLookup {
294
310
  readonly source: unknown
@@ -339,7 +355,8 @@ const updateKeys = (
339
355
  path: string,
340
356
  transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
341
357
  ): Readonly<Record<string, ReadonlyArray<string>>> => {
342
- const existing = state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
358
+ const existing =
359
+ state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
343
360
  return { ...state.arrayKeys, [path]: transform(existing) }
344
361
  }
345
362
 
@@ -351,7 +368,10 @@ const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
351
368
  const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
352
369
  return {
353
370
  ...withValues(input.state, nextValues),
354
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
371
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
372
+ ...keys,
373
+ makeArrayKey(),
374
+ ]),
355
375
  }
356
376
  }
357
377
 
@@ -382,7 +402,9 @@ const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Values> =>
382
402
  const nextValues = setNestedValue(input.state.values, input.path, next)
383
403
  return {
384
404
  ...withValues(input.state, nextValues),
385
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => moveAt(keys, input.from, input.to)),
405
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
406
+ moveAt(keys, input.from, input.to),
407
+ ),
386
408
  }
387
409
  }
388
410
 
@@ -397,7 +419,11 @@ const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> =>
397
419
  ) {
398
420
  return input.state
399
421
  }
400
- const swapped = replaceAt(replaceAt(elements, input.first, elements[input.second]), input.second, elements[input.first])
422
+ const swapped = replaceAt(
423
+ replaceAt(elements, input.first, elements[input.second]),
424
+ input.second,
425
+ elements[input.first],
426
+ )
401
427
  const nextValues = setNestedValue(input.state.values, input.path, swapped)
402
428
  return {
403
429
  ...withValues(input.state, nextValues),
@@ -411,7 +437,7 @@ const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> =>
411
437
  }
412
438
  }
413
439
 
414
- interface MakeConfig<Schema extends S.Schema.Any, Inputs extends InputRecord> {
440
+ interface MakeConfig<Schema extends S.Schema.AnyNoContext, Inputs extends InputRecord> {
415
441
  readonly schema: Schema
416
442
  // oxlint-disable-next-line reform-rules/no-optional-fields -- optional source map on the public make() config; Option would break callers passing only a schema
417
443
  readonly inputs?: Inputs
@@ -419,30 +445,51 @@ interface MakeConfig<Schema extends S.Schema.Any, Inputs extends InputRecord> {
419
445
 
420
446
  export const make = <
421
447
  const N extends string,
422
- Schema extends S.Schema.Any,
448
+ Schema extends S.Schema.AnyNoContext,
423
449
  const Inputs extends InputRecord = {},
424
450
  >(
425
451
  name: N,
426
452
  config: MakeConfig<Schema, Inputs>,
427
453
  ): FormClass<N, Schema, Inputs> => {
428
- const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = Fn.unsafeCoerce(
429
- State.make(`${name}/state`, unknownSchema, { title: `${name} form state` }),
454
+ const valuesSchema = S.encodedSchema(config.schema)
455
+ const stringRecord = S.Record({ key: S.String, value: S.String })
456
+ const stateSchema = S.Struct({
457
+ values: valuesSchema,
458
+ initialValues: valuesSchema,
459
+ touched: S.Record({ key: S.String, value: S.Boolean }),
460
+ errors: stringRecord,
461
+ dirtyPaths: S.Array(S.String),
462
+ submitCount: S.Number,
463
+ validationCount: S.Number,
464
+ lastSubmittedValues: S.OptionFromSelf(valuesSchema),
465
+ arrayKeys: S.Record({ key: S.String, value: S.Array(S.String) }),
466
+ })
467
+ const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = State.make(
468
+ `${name}/state`,
469
+ stateSchema,
470
+ { title: `${name} form state` },
430
471
  )
431
472
  const events: FormEvents = {
432
473
  set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
433
474
  blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
434
475
  reset: Event.make(`${name}/Reset`, S.Struct({})),
435
- validationFinished: Event.make(`${name}/ValidationFinished`, S.Struct({
436
- errors: S.Record({ key: S.String, value: S.String }),
437
- })),
476
+ validationFinished: Event.make(
477
+ `${name}/ValidationFinished`,
478
+ S.Struct({
479
+ errors: S.Record({ key: S.String, value: S.String }),
480
+ }),
481
+ ),
438
482
  submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
439
483
  submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
440
484
  append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
441
485
  remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
442
- move: Event.make(`${name}/MoveItem`, S.Struct({ path: S.String, from: S.Number, to: S.Number })),
486
+ move: Event.make(
487
+ `${name}/MoveItem`,
488
+ S.Struct({ path: S.String, from: S.Number, to: S.Number }),
489
+ ),
443
490
  swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
444
491
  }
445
- const reducer = Reducer.make(`${name}/FormReducer`, {
492
+ const reducer: FormClass<N, Schema, Inputs>['reducer'] = Reducer.make(`${name}/FormReducer`, {
446
493
  states: [state],
447
494
  events: Object.values(events),
448
495
  })
@@ -450,10 +497,12 @@ export const make = <
450
497
  RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
451
498
  RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
452
499
  >(`reform/form/${name}/config`)
453
- const inputs: Inputs = Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(config.inputs), () => ({})))
500
+ const inputs: Inputs = Fn.unsafeCoerce(
501
+ Option.getOrElse(Option.fromNullable(config.inputs), () => ({})),
502
+ )
454
503
  class FormImpl {
455
- static readonly manifest = {
456
- kind: 'Form' as const,
504
+ static readonly manifest: FormManifest<N, Schema, Inputs> = {
505
+ kind: 'Form',
457
506
  name,
458
507
  schema: config.schema,
459
508
  inputs,
@@ -462,33 +511,50 @@ export const make = <
462
511
  static readonly config = runtimeConfig
463
512
  static readonly events = events
464
513
  static readonly reducer = reducer
514
+ static capture<Result>(visitor: FormVisitor<Result>): Result {
515
+ return visitor.visit(FormImpl)
516
+ }
465
517
  }
466
- return Fn.unsafeCoerce(FormImpl)
518
+ return FormImpl
467
519
  }
468
520
 
469
521
  export const live = <
470
- F extends AnyForm,
522
+ N extends string,
523
+ Schema extends S.Schema.AnyNoContext,
524
+ FormInputs extends InputRecord,
471
525
  R = never,
472
526
  >(
473
- form: F,
474
- config: FormLiveConfig<Values<F>, Decoded<F>, Inputs<F>, R>,
527
+ form: FormClass<N, Schema, FormInputs>,
528
+ config: FormLiveConfig<
529
+ ValuesOfSchema<Schema>,
530
+ DecodedOfSchema<Schema>,
531
+ InputsObject<FormInputs>,
532
+ R
533
+ >,
475
534
  ): Layer.Layer<
476
- Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
535
+ | State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
536
+ | RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
477
537
  never,
478
538
  Bus | Reducers | R
479
539
  > => {
480
540
  const configTag: Context.Tag<
481
- RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
482
- RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>
483
- > = Fn.unsafeCoerce(form.config)
541
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
542
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
543
+ > = form.config
484
544
  const configLayer = Layer.effect(
485
545
  configTag,
486
546
  Effect.map(Effect.context<R | Bus>(), (context) => {
487
- const runtimeConfig: RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> = {
547
+ const runtimeConfig: RuntimeConfig<
548
+ ValuesOfSchema<Schema>,
549
+ DecodedOfSchema<Schema>,
550
+ InputsObject<FormInputs>
551
+ > = {
488
552
  initial: config.initial,
489
553
  validate: (ctx) => {
490
554
  const validationOutput = config.validate?.(ctx)
491
- const effect = Effect.isEffect(validationOutput) ? validationOutput : Effect.succeed(validationOutput)
555
+ const effect = Effect.isEffect(validationOutput)
556
+ ? validationOutput
557
+ : Effect.succeed(validationOutput)
492
558
  return Fn.unsafeCoerce(
493
559
  Effect.map(Effect.provide(effect, context), (rawErrors) => normalizeErrors(rawErrors)),
494
560
  )
@@ -499,9 +565,7 @@ export const live = <
499
565
  return Fn.unsafeCoerce(Effect.asVoid(Effect.provide(effect, context)))
500
566
  },
501
567
  }
502
- return config.limit === undefined
503
- ? runtimeConfig
504
- : { ...runtimeConfig, limit: config.limit }
568
+ return config.limit === undefined ? runtimeConfig : { ...runtimeConfig, limit: config.limit }
505
569
  }),
506
570
  )
507
571
 
@@ -517,12 +581,19 @@ export const live = <
517
581
 
518
582
  const reducerLayer = Reducer.live(
519
583
  form.reducer,
520
- (state: FormState<Values<F>>, event: InternalEvent): FormState<Values<F>> => {
584
+ (
585
+ state: FormState<ValuesOfSchema<Schema>>,
586
+ event: InternalEvent,
587
+ ): FormState<ValuesOfSchema<Schema>> => {
521
588
  if (event._tag === form.events.set.tag) {
522
- return 'path' in event && 'value' in event ? setAtPath({ state, path: event.path, value: event.value }) : state
589
+ return 'path' in event && 'value' in event
590
+ ? setAtPath({ state, path: event.path, value: event.value })
591
+ : state
523
592
  }
524
593
  if (event._tag === form.events.blur.tag) {
525
- return 'path' in event ? { ...state, touched: markTouched(state.touched, event.path) } : state
594
+ return 'path' in event
595
+ ? { ...state, touched: markTouched(state.touched, event.path) }
596
+ : state
526
597
  }
527
598
  if (event._tag === form.events.reset.tag) {
528
599
  return initialState(state.initialValues)
@@ -536,9 +607,15 @@ export const live = <
536
607
  return { ...state, submitCount: state.submitCount + 1 }
537
608
  }
538
609
  if (event._tag === form.events.submitSucceeded.tag) {
539
- return 'values' in event
540
- ? { ...state, lastSubmittedValues: Option.some(Fn.unsafeCoerce<unknown, Values<F>>(event.values)) }
541
- : state
610
+ if (!('values' in event)) {
611
+ return state
612
+ }
613
+ return {
614
+ ...state,
615
+ lastSubmittedValues: Option.some(
616
+ Fn.unsafeCoerce<unknown, ValuesOfSchema<Schema>>(event.values),
617
+ ),
618
+ }
542
619
  }
543
620
  if (event._tag === form.events.append.tag) {
544
621
  return 'path' in event && 'value' in event
@@ -564,26 +641,23 @@ export const live = <
564
641
  },
565
642
  )
566
643
 
567
- return Fn.unsafeCoerce(
568
- Layer.mergeAll(configLayer, reducerLayer).pipe(
569
- Layer.provideMerge(State.live(form.state, initialState(config.initial))),
570
- ),
644
+ return Layer.mergeAll(configLayer, reducerLayer).pipe(
645
+ Layer.provideMerge(State.live(form.state, initialState(config.initial))),
571
646
  )
572
647
  }
573
648
 
574
- const readInput = <Source extends AnySource>(
575
- source: Source,
576
- ): Effect.Effect<SourceValue<Source>, never, Store<SourceValue<Source>>> =>
577
- Fn.unsafeCoerce(
578
- Effect.gen(function* () {
579
- const store = yield* source.store
580
- const tracker = yield* Effect.serviceOption(CurrentTracker)
581
- if (Option.isSome(tracker)) {
582
- tracker.value.add(store)
583
- }
584
- return store.getSnapshot()
585
- }),
649
+ const readInput = Effect.fn('forms.readInput')(function* <Input extends AnySource>(
650
+ source: Input,
651
+ ): Effect.fn.Return<ReformSourceValue<Input>, never, SourceIdentifier<Input>> {
652
+ const store = yield* Context.GenericTag<SourceIdentifier<Input>, Store<ReformSourceValue<Input>>>(
653
+ source.store.key,
586
654
  )
655
+ const tracker = yield* Effect.serviceOption(CurrentTracker)
656
+ if (Option.isSome(tracker)) {
657
+ tracker.value.add(store)
658
+ }
659
+ return store.getSnapshot()
660
+ })
587
661
 
588
662
  const readInputs = <Inputs extends InputRecord>(
589
663
  inputs: Inputs,
@@ -591,35 +665,41 @@ const readInputs = <Inputs extends InputRecord>(
591
665
  Fn.unsafeCoerce(
592
666
  Effect.gen(function* () {
593
667
  const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
594
- Effect.map(readInput(source), (snapshot) => [source.name, snapshot] as const),
668
+ Effect.map(readInput(source), (snapshot): readonly [string, unknown] => [
669
+ source.name,
670
+ snapshot,
671
+ ]),
595
672
  )
596
673
  return Record.fromEntries(pairs)
597
674
  }),
598
675
  )
599
676
 
600
- const limitationsFor = <A>(
601
- limitations: Limitations,
602
- path: string,
603
- ): FieldLimitations<A> =>
677
+ const limitationsFor = <A>(limitations: Limitations, path: string): FieldLimitations<A> =>
604
678
  Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
605
679
 
606
- const trigger = <P>(eventTrigger: Effect.Effect<Trigger<P>, never, Bus>): Effect.Effect<Trigger<P>, never, Bus> =>
607
- eventTrigger
680
+ const trigger = <P>(
681
+ eventTrigger: Effect.Effect<Trigger<P>, never, Bus>,
682
+ ): Effect.Effect<Trigger<P>, never, Bus> => eventTrigger
608
683
 
609
- // oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view: the explicit R (Store | RuntimeConfig | Bus | InputStores) must be preserved verbatim; Effect.fn would re-infer requirements off the erased AnyForm wildcard
610
- export const view = <F extends AnyForm>(
611
- form: F,
612
- ): Effect.Effect<View<F>, never, Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> | Bus | InputStores<F['manifest']['inputs']>> =>
684
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view preserves the exact form state/config/input requirements verbatim
685
+ export const view = <
686
+ N extends string,
687
+ Schema extends S.Schema.AnyNoContext,
688
+ FormInputs extends InputRecord,
689
+ >(
690
+ form: FormClass<N, Schema, FormInputs>,
691
+ ): Effect.Effect<
692
+ FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>>,
693
+ never,
694
+ | State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
695
+ | RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
696
+ | Bus
697
+ | InputStores<FormInputs>
698
+ > =>
613
699
  Effect.gen(function* () {
614
700
  const state = yield* form.state
615
701
  const runtimeConfig = yield* form.config
616
- // Erased boundary (same shape as the coercions closing `live`/`readInputs`):
617
- // under the `AnyForm` constraint `form.manifest.inputs` is the wildcard
618
- // `any`, which would dissolve the generator's R to `unknown`; restate the
619
- // call at the concrete `F`'s types so R keeps the declared input stores.
620
- const inputs: Inputs<F> = Fn.unsafeCoerce(
621
- yield* readInputs(Fn.unsafeCoerce<typeof form.manifest.inputs, InputRecord>(form.manifest.inputs)),
622
- )
702
+ const inputs = yield* readInputs(form.manifest.inputs)
623
703
  const limitations = Option.getOrElse(
624
704
  Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
625
705
  () => ({}),
@@ -633,7 +713,7 @@ export const view = <F extends AnyForm>(
633
713
  const swap = yield* trigger(form.events.swap.trigger)
634
714
  const runtime = yield* Effect.runtime<Bus>()
635
715
 
636
- const schema: S.Schema<Decoded<F>, Values<F>, never> = Fn.unsafeCoerce(form.manifest.schema)
716
+ const schema = form.manifest.schema
637
717
  const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
638
718
  const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
639
719
 
@@ -645,15 +725,21 @@ export const view = <F extends AnyForm>(
645
725
  }
646
726
  const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
647
727
  if (Either.isLeft(decoded)) {
648
- yield* Event.dispatch(form.events.validationFinished, { errors: routeParseError(decoded.left) })
649
- return Option.none<Decoded<F>>()
728
+ yield* Event.dispatch(form.events.validationFinished, {
729
+ errors: routeParseError(decoded.left),
730
+ })
731
+ return Option.none<DecodedOfSchema<Schema>>()
650
732
  }
651
- const custom = yield* runtimeConfig.validate({ values: state.values, decoded: decoded.right, inputs })
733
+ const custom = yield* runtimeConfig.validate({
734
+ values: state.values,
735
+ decoded: decoded.right,
736
+ inputs,
737
+ })
652
738
  const errors = errorsToRecord(custom)
653
739
  yield* Event.dispatch(form.events.validationFinished, { errors })
654
740
  return Record.keys(errors).length === 0
655
- ? Option.some(Fn.unsafeCoerce<unknown, Decoded<F>>(decoded.right))
656
- : Option.none<Decoded<F>>()
741
+ ? Option.some(decoded.right)
742
+ : Option.none<DecodedOfSchema<Schema>>()
657
743
  })
658
744
 
659
745
  const submit = () => {
@@ -665,7 +751,9 @@ export const view = <F extends AnyForm>(
665
751
  }
666
752
  yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
667
753
  yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
668
- }).pipe(Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause))),
754
+ }).pipe(
755
+ Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause)),
756
+ ),
669
757
  )
670
758
  }
671
759
 
@@ -673,16 +761,21 @@ export const view = <F extends AnyForm>(
673
761
  Runtime.runFork(runtime)(runValidation(false))
674
762
  }
675
763
 
676
- const field = <P extends FieldPath<Values<F>>>(path: P): FieldBinding<PathValue<Values<F>, P>> => {
677
- const fieldValue: PathValue<Values<F>, P> = Fn.unsafeCoerce(getNestedValue(state.values, path))
764
+ const field = <P extends FieldPath<ValuesOfSchema<Schema>>>(
765
+ path: P,
766
+ ): FieldBinding<PathValue<ValuesOfSchema<Schema>, P>> => {
767
+ const fieldValue: PathValue<ValuesOfSchema<Schema>, P> = Fn.unsafeCoerce(
768
+ getNestedValue(state.values, path),
769
+ )
678
770
  return {
679
771
  path,
680
772
  value: fieldValue,
681
773
  set: (next) => {
682
774
  if (typeof next === 'function') {
683
- const updater = Fn.unsafeCoerce<typeof next, (prev: PathValue<Values<F>, P>) => PathValue<Values<F>, P>>(
684
- next,
685
- )
775
+ const updater = Fn.unsafeCoerce<
776
+ typeof next,
777
+ (prev: PathValue<ValuesOfSchema<Schema>, P>) => PathValue<ValuesOfSchema<Schema>, P>
778
+ >(next)
686
779
  setField({ path, value: updater(fieldValue) })
687
780
  return
688
781
  }
@@ -693,12 +786,14 @@ export const view = <F extends AnyForm>(
693
786
  dirty: isPathOrParentDirty(state.dirtyPaths, path),
694
787
  touched: state.touched[path] === true,
695
788
  validating: false,
696
- limitations: limitationsFor<PathValue<Values<F>, P>>(limitations, path),
789
+ limitations: limitationsFor<PathValue<ValuesOfSchema<Schema>, P>>(limitations, path),
697
790
  }
698
791
  }
699
792
 
700
- const array = <P extends ArrayPath<Values<F>>>(path: P): ArrayBinding<ArrayItem<Values<F>, P>> => {
701
- const arrayValues: ReadonlyArray<ArrayItem<Values<F>, P>> = Fn.unsafeCoerce(
793
+ const array = <P extends ArrayPath<ValuesOfSchema<Schema>>>(
794
+ path: P,
795
+ ): ArrayBinding<ArrayItem<ValuesOfSchema<Schema>, P>> => {
796
+ const arrayValues: ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>> = Fn.unsafeCoerce(
702
797
  currentArray({ source: state.values, path }),
703
798
  )
704
799
  const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
@@ -715,11 +810,14 @@ export const view = <F extends AnyForm>(
715
810
  remove: (index) => remove({ path, index }),
716
811
  move: (from, to) => move({ path, from, to }),
717
812
  swap: (first, second) => swap({ path, a: first, b: second }),
718
- limitations: limitationsFor<ReadonlyArray<ArrayItem<Values<F>, P>>>(limitations, path),
813
+ limitations: limitationsFor<ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>>>(
814
+ limitations,
815
+ path,
816
+ ),
719
817
  }
720
818
  }
721
819
 
722
- const formView: View<F> = Fn.unsafeCoerce({
820
+ const formView: FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>> = Fn.unsafeCoerce({
723
821
  values: state.values,
724
822
  inputs,
725
823
  errors: state.errors,
package/src/index.ts CHANGED
@@ -16,13 +16,7 @@ export type {
16
16
  Values,
17
17
  View,
18
18
  } from './form'
19
- export {
20
- arrayKeysFor as unsafeArrayKeysFor,
21
- error,
22
- live,
23
- make,
24
- view,
25
- } from './form'
19
+ export { arrayKeysFor as unsafeArrayKeysFor, error, live, make, view } from './form'
26
20
  export type {
27
21
  ArrayItem,
28
22
  ArrayPath,
package/src/path.ts CHANGED
@@ -5,8 +5,7 @@ import { sort as sortArray } from 'effect/Array'
5
5
  const BRACKET_NOTATION_REGEX = /\[(\d+)\]/g
6
6
  const NO_INDEX = -1
7
7
 
8
- // `null` is a genuine leaf of arbitrary form values (JSON-shaped user data); it
9
- // is matched here to stop path recursion at primitive leaves.
8
+ // JSON null is a real form-value leaf stop path recursion here.
10
9
  export type Primitive =
11
10
  | string
12
11
  | number
@@ -25,52 +24,48 @@ type IsTagged<T> = T extends Tagged ? true : false
25
24
  export type FieldPath<T> = T extends Primitive
26
25
  ? never
27
26
  : {
28
- readonly [K in StringKey<T>]:
29
- T[K] extends ReadonlyArray<unknown>
27
+ readonly [K in StringKey<T>]: T[K] extends ReadonlyArray<unknown>
28
+ ? never
29
+ : IsTagged<T[K]> extends true
30
30
  ? never
31
- : IsTagged<T[K]> extends true
32
- ? never
33
- : T[K] extends Primitive
34
- ? K
35
- : T[K] extends object
36
- ? K | `${K}.${FieldPath<T[K]>}`
37
- : K
31
+ : T[K] extends Primitive
32
+ ? K
33
+ : T[K] extends object
34
+ ? K | `${K}.${FieldPath<T[K]>}`
35
+ : K
38
36
  }[StringKey<T>]
39
37
 
40
38
  export type ArrayPath<T> = T extends Primitive
41
39
  ? never
42
40
  : {
43
- readonly [K in StringKey<T>]:
44
- T[K] extends ReadonlyArray<unknown>
45
- ? K
46
- : IsTagged<T[K]> extends true
47
- ? never
48
- : T[K] extends object
49
- ? `${K}.${ArrayPath<T[K]>}`
50
- : never
41
+ readonly [K in StringKey<T>]: T[K] extends ReadonlyArray<unknown>
42
+ ? K
43
+ : IsTagged<T[K]> extends true
44
+ ? never
45
+ : T[K] extends object
46
+ ? `${K}.${ArrayPath<T[K]>}`
47
+ : never
51
48
  }[StringKey<T>]
52
49
 
53
50
  export type VariantPath<T> = T extends Primitive
54
51
  ? never
55
52
  : {
56
- readonly [K in StringKey<T>]:
57
- IsTagged<T[K]> extends true
58
- ? K
59
- : T[K] extends ReadonlyArray<unknown>
60
- ? never
61
- : T[K] extends object
62
- ? `${K}.${VariantPath<T[K]>}`
63
- : never
53
+ readonly [K in StringKey<T>]: IsTagged<T[K]> extends true
54
+ ? K
55
+ : T[K] extends ReadonlyArray<unknown>
56
+ ? never
57
+ : T[K] extends object
58
+ ? `${K}.${VariantPath<T[K]>}`
59
+ : never
64
60
  }[StringKey<T>]
65
61
 
66
- export type PathValue<T, P extends string> =
67
- P extends `${infer Head}.${infer Tail}`
68
- ? Head extends keyof T
69
- ? PathValue<T[Head], Tail>
70
- : never
71
- : P extends keyof T
72
- ? T[P]
73
- : never
62
+ export type PathValue<T, P extends string> = P extends `${infer Head}.${infer Tail}`
63
+ ? Head extends keyof T
64
+ ? PathValue<T[Head], Tail>
65
+ : never
66
+ : P extends keyof T
67
+ ? T[P]
68
+ : never
74
69
 
75
70
  export type ArrayItem<T, P extends string> =
76
71
  PathValue<T, P> extends ReadonlyArray<infer Item> ? Item : never
@@ -83,8 +78,6 @@ export type VariantCase<V, Tag extends string> = Extract<V, { readonly _tag: Tag
83
78
 
84
79
  export type VariantBody<V, Tag extends string> = Omit<VariantCase<V, Tag>, '_tag'>
85
80
 
86
- // Typed view over an arbitrary object for string-keyed traversal of unknown form
87
- // values. `unsafeCoerce` is the Effect-blessed identity coercion (no `as`).
88
81
  const asRecord = (source: unknown): Record<string, unknown> => Fn.unsafeCoerce(source)
89
82
 
90
83
  export const schemaPathToFieldPath = (path: ReadonlyArray<PropertyKey>): string =>
@@ -199,7 +192,9 @@ export const recalculateDirtyPaths = (
199
192
  ) {
200
193
  const currentRecord = asRecord(current)
201
194
  const originalRecord = asRecord(original)
202
- const keys = Array.from(new Set([...Record.keys(currentRecord), ...Record.keys(originalRecord)]))
195
+ const keys = Array.from(
196
+ new Set([...Record.keys(currentRecord), ...Record.keys(originalRecord)]),
197
+ )
203
198
  return keys.flatMap((key) =>
204
199
  visit({
205
200
  current: currentRecord[key],