@playfast/reform-forms 1.1.1 → 1.2.1

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.1.1",
5
- "type": "module",
3
+ "version": "1.2.1",
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,7 +1,7 @@
1
- import { expect, it } from "@effect/vitest";
2
- import { Effect, Layer, Option, Schema as S } from "effect";
3
- import { Engine } from "@playfast/reform";
4
- import * as Form from "./form";
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Layer, Option, Schema as S } from 'effect'
3
+ import { Engine } from '@playfast/reform'
4
+ import * as Form from './form'
5
5
 
6
6
  const waitUntil = <A, R>(
7
7
  read: Effect.Effect<A, never, R>,
@@ -11,81 +11,66 @@ const waitUntil = <A, R>(
11
11
  Effect.flatMap(read, (value) =>
12
12
  pred(value) || rounds <= 0
13
13
  ? Effect.succeed(value)
14
- : Effect.flatMap(Effect.yieldNow(), () =>
15
- waitUntil(read, pred, rounds - 1),
16
- ),
17
- );
14
+ : Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
15
+ )
18
16
 
19
17
  const CheckoutSchema = S.Struct({
20
18
  email: S.String,
21
19
  items: S.Array(S.Struct({ name: S.String, qty: S.Number })),
22
20
  payment: S.Union(
23
- S.TaggedStruct("Card", { cardNumber: S.String }),
24
- S.TaggedStruct("Paypal", { email: S.String }),
21
+ S.TaggedStruct('Card', { cardNumber: S.String }),
22
+ S.TaggedStruct('Paypal', { email: S.String }),
25
23
  ),
26
- });
24
+ })
27
25
 
28
- class CheckoutForm extends Form.make("CheckoutForm", {
26
+ class CheckoutForm extends Form.make('CheckoutForm', {
29
27
  schema: CheckoutSchema,
30
28
  }) {}
31
29
 
32
30
  const layer = Form.live(CheckoutForm, {
33
31
  initial: {
34
- email: "",
32
+ email: '',
35
33
  items: [],
36
- payment: { _tag: "Card", cardNumber: "" },
34
+ payment: { _tag: 'Card', cardNumber: '' },
37
35
  },
38
36
  validate: ({ values }) =>
39
- values.email.includes("@")
40
- ? undefined
41
- : Form.error("email", "Email must contain @"),
42
- }).pipe(Layer.provideMerge(Engine));
37
+ values.email.includes('@') ? undefined : Form.error('email', 'Email must contain @'),
38
+ }).pipe(Layer.provideMerge(Engine))
43
39
 
44
- it.scopedLive("field bindings write through the form reducer", () =>
40
+ it.scopedLive('field bindings write through the form reducer', () =>
45
41
  Effect.gen(function* () {
46
- const view = yield* Form.view(CheckoutForm);
47
- view.field("email").set("a@example.com");
42
+ const view = yield* Form.view(CheckoutForm)
43
+ view.field('email').set('a@example.com')
48
44
 
49
45
  const next = yield* waitUntil(
50
46
  Form.view(CheckoutForm),
51
- (v) => v.values.email === "a@example.com",
52
- );
53
- expect(next.field("email").dirty).toBe(true);
47
+ (v) => v.values.email === 'a@example.com',
48
+ )
49
+ expect(next.field('email').dirty).toBe(true)
54
50
  }).pipe(Effect.provide(layer)),
55
- );
51
+ )
56
52
 
57
- it.scopedLive("array bindings append and remove items", () =>
53
+ it.scopedLive('array bindings append and remove items', () =>
58
54
  Effect.gen(function* () {
59
- const view = yield* Form.view(CheckoutForm);
60
- view.array("items").append({ name: "milk", qty: 1 });
55
+ const view = yield* Form.view(CheckoutForm)
56
+ view.array('items').append({ name: 'milk', qty: 1 })
61
57
 
62
- const withItem = yield* waitUntil(
63
- Form.view(CheckoutForm),
64
- (v) => v.values.items.length === 1,
65
- );
66
- 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-/)
67
60
 
68
- withItem.array("items").remove(0);
69
- const empty = yield* waitUntil(
70
- Form.view(CheckoutForm),
71
- (v) => v.values.items.length === 0,
72
- );
73
- expect(empty.values.items).toEqual([]);
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([])
74
64
  }).pipe(Effect.provide(layer)),
75
- );
65
+ )
76
66
 
77
- it.scopedLive("submit routes custom validation errors to fields", () =>
67
+ it.scopedLive('submit routes custom validation errors to fields', () =>
78
68
  Effect.gen(function* () {
79
- const view = yield* Form.view(CheckoutForm);
80
- view.submit();
69
+ const view = yield* Form.view(CheckoutForm)
70
+ view.submit()
81
71
 
82
- const invalid = yield* waitUntil(
83
- Form.view(CheckoutForm),
84
- (v) => v.validationCount > 0,
85
- );
86
- const error = invalid.field("email").error;
87
- expect(Option.isSome(error) ? error.value : undefined).toBe(
88
- "Email must contain @",
89
- );
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 @')
90
75
  }).pipe(Effect.provide(layer)),
91
- );
76
+ )
package/src/form.ts CHANGED
@@ -1,51 +1,31 @@
1
+ import { type Context, Effect, Function as Fn, Layer, Option, Schema as S } from 'effect'
2
+ import { Reducer, State } from '@playfast/reform'
3
+ import { Bus, Reducers } from '@playfast/reform/internal'
4
+ import { error, normalizeErrors, type FormErrors } from './validation'
1
5
  import {
2
- Context,
3
- Effect,
4
- Function as Fn,
5
- Layer,
6
- Option,
7
- Schema as S,
8
- } from 'effect'
9
- import {
10
- Bus,
11
- Event,
12
- Reducer,
13
- Reducers,
14
- State,
15
- type Store,
16
- } from '@playfast/reform'
6
+ appendAtPath,
7
+ initialState,
8
+ markTouched,
9
+ moveAtPath,
10
+ removeAtPath,
11
+ setAtPath,
12
+ swapAtPath,
13
+ } from './formState'
17
14
  import type {
18
- AnyForm,
19
- Decoded,
20
15
  DecodedOfSchema,
21
16
  FormClass,
22
- FormEvents,
23
17
  FormLiveConfig,
24
18
  FormState,
25
19
  InputRecord,
26
- Inputs,
27
20
  InputsObject,
28
21
  RuntimeConfig,
29
- Values,
30
22
  ValuesOfSchema,
31
23
  } from './formTypes'
32
- import {
33
- appendAtPath,
34
- initialState,
35
- markTouched,
36
- moveAtPath,
37
- removeAtPath,
38
- setAtPath,
39
- swapAtPath,
40
- } from './formState'
41
- import {
42
- error,
43
- normalizeErrors,
44
- type FormErrors,
45
- } from './validation'
46
24
 
47
25
  export { error }
48
-
26
+ export { arrayKeysFor } from './formState'
27
+ export { make } from './formMake'
28
+ export { view } from './formView'
49
29
  export type {
50
30
  AnyForm,
51
31
  ArrayBinding,
@@ -57,8 +37,11 @@ export type {
57
37
  FormClass,
58
38
  FormLiveConfig,
59
39
  FormManifest,
40
+ FormManifestReflection,
41
+ FormSchemaReflection,
60
42
  FormState,
61
43
  FormView,
44
+ FormVisitor,
62
45
  Inputs,
63
46
  Limitations,
64
47
  RuntimeConfig,
@@ -66,88 +49,43 @@ export type {
66
49
  View,
67
50
  } from './formTypes'
68
51
 
69
- const unknownSchema: S.Schema.AnyNoContext = Fn.unsafeCoerce(S.Unknown)
70
-
71
- export { arrayKeysFor } from './formState'
72
-
73
- interface MakeConfig<Schema extends S.Schema.Any, Inputs extends InputRecord> {
74
- readonly schema: Schema
75
- // 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
76
- readonly inputs?: Inputs
77
- }
78
-
79
- export const make = <
80
- const N extends string,
81
- Schema extends S.Schema.Any,
82
- const Inputs extends InputRecord = {},
83
- >(
84
- name: N,
85
- config: MakeConfig<Schema, Inputs>,
86
- ): FormClass<N, Schema, Inputs> => {
87
- const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = Fn.unsafeCoerce(
88
- State.make(`${name}/state`, unknownSchema, { title: `${name} form state` }),
89
- )
90
- const events: FormEvents = {
91
- set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
92
- blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
93
- reset: Event.make(`${name}/Reset`, S.Struct({})),
94
- validationFinished: Event.make(`${name}/ValidationFinished`, S.Struct({
95
- errors: S.Record({ key: S.String, value: S.String }),
96
- })),
97
- submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
98
- submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
99
- append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
100
- remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
101
- move: Event.make(`${name}/MoveItem`, S.Struct({ path: S.String, from: S.Number, to: S.Number })),
102
- swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
103
- }
104
- const reducer = Reducer.make(`${name}/FormReducer`, {
105
- states: [state],
106
- events: Object.values(events),
107
- })
108
- const runtimeConfig = Context.GenericTag<
109
- RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
110
- RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
111
- >(`reform/form/${name}/config`)
112
- const inputs: Inputs = Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(config.inputs), () => ({})))
113
- class FormImpl {
114
- static readonly manifest = {
115
- kind: 'Form' as const,
116
- name,
117
- schema: config.schema,
118
- inputs,
119
- }
120
- static readonly state = state
121
- static readonly config = runtimeConfig
122
- static readonly events = events
123
- static readonly reducer = reducer
124
- }
125
- return Fn.unsafeCoerce(FormImpl)
126
- }
127
-
128
52
  export const live = <
129
- F extends AnyForm,
53
+ N extends string,
54
+ Schema extends S.Schema.AnyNoContext,
55
+ FormInputs extends InputRecord,
130
56
  R = never,
131
57
  >(
132
- form: F,
133
- config: FormLiveConfig<Values<F>, Decoded<F>, Inputs<F>, R>,
58
+ form: FormClass<N, Schema, FormInputs>,
59
+ config: FormLiveConfig<
60
+ ValuesOfSchema<Schema>,
61
+ DecodedOfSchema<Schema>,
62
+ InputsObject<FormInputs>,
63
+ R
64
+ >,
134
65
  ): Layer.Layer<
135
- Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
66
+ | State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
67
+ | RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
136
68
  never,
137
69
  Bus | Reducers | R
138
70
  > => {
139
71
  const configTag: Context.Tag<
140
- RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
141
- RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>
142
- > = Fn.unsafeCoerce(form.config)
72
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
73
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
74
+ > = form.config
143
75
  const configLayer = Layer.effect(
144
76
  configTag,
145
77
  Effect.map(Effect.context<R | Bus>(), (context) => {
146
- const runtimeConfig: RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> = {
78
+ const runtimeConfig: RuntimeConfig<
79
+ ValuesOfSchema<Schema>,
80
+ DecodedOfSchema<Schema>,
81
+ InputsObject<FormInputs>
82
+ > = {
147
83
  initial: config.initial,
148
84
  validate: (ctx) => {
149
85
  const validationOutput = config.validate?.(ctx)
150
- const effect = Effect.isEffect(validationOutput) ? validationOutput : Effect.succeed(validationOutput)
86
+ const effect = Effect.isEffect(validationOutput)
87
+ ? validationOutput
88
+ : Effect.succeed(validationOutput)
151
89
  return Fn.unsafeCoerce(
152
90
  Effect.map(Effect.provide(effect, context), (rawErrors) => normalizeErrors(rawErrors)),
153
91
  )
@@ -158,9 +96,7 @@ export const live = <
158
96
  return Fn.unsafeCoerce(Effect.asVoid(Effect.provide(effect, context)))
159
97
  },
160
98
  }
161
- return config.limit === undefined
162
- ? runtimeConfig
163
- : { ...runtimeConfig, limit: config.limit }
99
+ return config.limit === undefined ? runtimeConfig : { ...runtimeConfig, limit: config.limit }
164
100
  }),
165
101
  )
166
102
 
@@ -176,12 +112,19 @@ export const live = <
176
112
 
177
113
  const reducerLayer = Reducer.live(
178
114
  form.reducer,
179
- (state: FormState<Values<F>>, event: InternalEvent): FormState<Values<F>> => {
115
+ (
116
+ state: FormState<ValuesOfSchema<Schema>>,
117
+ event: InternalEvent,
118
+ ): FormState<ValuesOfSchema<Schema>> => {
180
119
  if (event._tag === form.events.set.tag) {
181
- return 'path' in event && 'value' in event ? setAtPath({ state, path: event.path, value: event.value }) : state
120
+ return 'path' in event && 'value' in event
121
+ ? setAtPath({ state, path: event.path, value: event.value })
122
+ : state
182
123
  }
183
124
  if (event._tag === form.events.blur.tag) {
184
- return 'path' in event ? { ...state, touched: markTouched(state.touched, event.path) } : state
125
+ return 'path' in event
126
+ ? { ...state, touched: markTouched(state.touched, event.path) }
127
+ : state
185
128
  }
186
129
  if (event._tag === form.events.reset.tag) {
187
130
  return initialState(state.initialValues)
@@ -195,9 +138,15 @@ export const live = <
195
138
  return { ...state, submitCount: state.submitCount + 1 }
196
139
  }
197
140
  if (event._tag === form.events.submitSucceeded.tag) {
198
- return 'values' in event
199
- ? { ...state, lastSubmittedValues: Option.some(Fn.unsafeCoerce<unknown, Values<F>>(event.values)) }
200
- : state
141
+ if (!('values' in event)) {
142
+ return state
143
+ }
144
+ return {
145
+ ...state,
146
+ lastSubmittedValues: Option.some(
147
+ Fn.unsafeCoerce<unknown, ValuesOfSchema<Schema>>(event.values),
148
+ ),
149
+ }
201
150
  }
202
151
  if (event._tag === form.events.append.tag) {
203
152
  return 'path' in event && 'value' in event
@@ -223,11 +172,7 @@ export const live = <
223
172
  },
224
173
  )
225
174
 
226
- return Fn.unsafeCoerce(
227
- Layer.mergeAll(configLayer, reducerLayer).pipe(
228
- Layer.provideMerge(State.live(form.state, initialState(config.initial))),
229
- ),
175
+ return Layer.mergeAll(configLayer, reducerLayer).pipe(
176
+ Layer.provideMerge(State.live(form.state, initialState(config.initial))),
230
177
  )
231
178
  }
232
-
233
- export { view } from './formView'
@@ -0,0 +1,95 @@
1
+ import { Context, Function as Fn, Option, Schema as S } from 'effect'
2
+ import { Event, Reducer, State } from '@playfast/reform'
3
+ import type {
4
+ DecodedOfSchema,
5
+ FormClass,
6
+ FormEvents,
7
+ FormManifest,
8
+ FormState,
9
+ FormVisitor,
10
+ InputRecord,
11
+ InputsObject,
12
+ RuntimeConfig,
13
+ ValuesOfSchema,
14
+ } from './formTypes'
15
+
16
+ interface MakeConfig<Schema extends S.Schema.AnyNoContext, Inputs extends InputRecord> {
17
+ readonly schema: Schema
18
+ // 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
19
+ readonly inputs?: Inputs
20
+ }
21
+
22
+ export const make = <
23
+ const N extends string,
24
+ Schema extends S.Schema.AnyNoContext,
25
+ const Inputs extends InputRecord = {},
26
+ >(
27
+ name: N,
28
+ config: MakeConfig<Schema, Inputs>,
29
+ ): FormClass<N, Schema, Inputs> => {
30
+ const valuesSchema = S.encodedSchema(config.schema)
31
+ const stringRecord = S.Record({ key: S.String, value: S.String })
32
+ const stateSchema = S.Struct({
33
+ values: valuesSchema,
34
+ initialValues: valuesSchema,
35
+ touched: S.Record({ key: S.String, value: S.Boolean }),
36
+ errors: stringRecord,
37
+ dirtyPaths: S.Array(S.String),
38
+ submitCount: S.Number,
39
+ validationCount: S.Number,
40
+ lastSubmittedValues: S.OptionFromSelf(valuesSchema),
41
+ arrayKeys: S.Record({ key: S.String, value: S.Array(S.String) }),
42
+ })
43
+ const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = State.make(
44
+ `${name}/state`,
45
+ stateSchema,
46
+ { title: `${name} form state` },
47
+ )
48
+ const events: FormEvents = {
49
+ set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
50
+ blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
51
+ reset: Event.make(`${name}/Reset`, S.Struct({})),
52
+ validationFinished: Event.make(
53
+ `${name}/ValidationFinished`,
54
+ S.Struct({
55
+ errors: S.Record({ key: S.String, value: S.String }),
56
+ }),
57
+ ),
58
+ submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
59
+ submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
60
+ append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
61
+ remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
62
+ move: Event.make(
63
+ `${name}/MoveItem`,
64
+ S.Struct({ path: S.String, from: S.Number, to: S.Number }),
65
+ ),
66
+ swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
67
+ }
68
+ const reducer: FormClass<N, Schema, Inputs>['reducer'] = Reducer.make(`${name}/FormReducer`, {
69
+ states: [state],
70
+ events: Object.values(events),
71
+ })
72
+ const runtimeConfig = Context.GenericTag<
73
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
74
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
75
+ >(`reform/form/${name}/config`)
76
+ const inputs: Inputs = Fn.unsafeCoerce(
77
+ Option.getOrElse(Option.fromNullable(config.inputs), () => ({})),
78
+ )
79
+ class FormImpl {
80
+ static readonly manifest: FormManifest<N, Schema, Inputs> = {
81
+ kind: 'Form',
82
+ name,
83
+ schema: config.schema,
84
+ inputs,
85
+ }
86
+ static readonly state = state
87
+ static readonly config = runtimeConfig
88
+ static readonly events = events
89
+ static readonly reducer = reducer
90
+ static capture<Result>(visitor: FormVisitor<Result>): Result {
91
+ return visitor.visit(FormImpl)
92
+ }
93
+ }
94
+ return FormImpl
95
+ }
package/src/formState.ts CHANGED
@@ -16,12 +16,15 @@ export const arrayKeysFor = (source: unknown, path = ''): Record<string, Readonl
16
16
  const visit = (node: KeyVisitNode): void => {
17
17
  if (Array.isArray(node.current)) {
18
18
  out[node.path] = node.current.map(() => makeArrayKey())
19
- node.current.forEach((element, index) => visit({ current: element, path: `${node.path}[${index}]` }))
19
+ node.current.forEach((element, index) =>
20
+ visit({ current: element, path: `${node.path}[${index}]` }),
21
+ )
20
22
  return
21
23
  }
22
24
  if (node.current !== null && typeof node.current === 'object') {
23
- Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(([key, child]) =>
24
- visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
25
+ Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
26
+ ([key, child]) =>
27
+ visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
25
28
  )
26
29
  }
27
30
  }
@@ -41,8 +44,10 @@ export const initialState = <Values>(initial: Values): FormState<Values> => ({
41
44
  arrayKeys: arrayKeysFor(initial),
42
45
  })
43
46
 
44
- export const markTouched = (touched: Readonly<Record<string, boolean>>, path: string): Readonly<Record<string, boolean>> =>
45
- ({ ...touched, [path]: true })
47
+ export const markTouched = (
48
+ touched: Readonly<Record<string, boolean>>,
49
+ path: string,
50
+ ): Readonly<Record<string, boolean>> => ({ ...touched, [path]: true })
46
51
 
47
52
  interface ArrayLookup {
48
53
  readonly source: unknown
@@ -93,7 +98,8 @@ const updateKeys = (
93
98
  path: string,
94
99
  transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
95
100
  ): Readonly<Record<string, ReadonlyArray<string>>> => {
96
- const existing = state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
101
+ const existing =
102
+ state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
97
103
  return { ...state.arrayKeys, [path]: transform(existing) }
98
104
  }
99
105
 
@@ -105,7 +111,10 @@ export const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Val
105
111
  const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
106
112
  return {
107
113
  ...withValues(input.state, nextValues),
108
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
114
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
115
+ ...keys,
116
+ makeArrayKey(),
117
+ ]),
109
118
  }
110
119
  }
111
120
 
@@ -136,7 +145,9 @@ export const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Valu
136
145
  const nextValues = setNestedValue(input.state.values, input.path, next)
137
146
  return {
138
147
  ...withValues(input.state, nextValues),
139
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => moveAt(keys, input.from, input.to)),
148
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
149
+ moveAt(keys, input.from, input.to),
150
+ ),
140
151
  }
141
152
  }
142
153
 
@@ -151,7 +162,11 @@ export const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Valu
151
162
  ) {
152
163
  return input.state
153
164
  }
154
- const swapped = replaceAt(replaceAt(elements, input.first, elements[input.second]), input.second, elements[input.first])
165
+ const swapped = replaceAt(
166
+ replaceAt(elements, input.first, elements[input.second]),
167
+ input.second,
168
+ elements[input.first],
169
+ )
155
170
  const nextValues = setNestedValue(input.state.values, input.path, swapped)
156
171
  return {
157
172
  ...withValues(input.state, nextValues),
package/src/formTypes.ts CHANGED
@@ -1,5 +1,10 @@
1
- import { Context, Effect, Option, Schema as S } from 'effect'
2
- import type { AnySource, Event, Reducer, Source, State } from '@playfast/reform'
1
+ import type { Context, Effect, Option, Schema as S } from 'effect'
2
+ import type { Event, Reducer, State } from '@playfast/reform'
3
+ import type {
4
+ AnySource,
5
+ SourceName,
6
+ SourceValue as ReformSourceValue,
7
+ } from '@playfast/reform/internal'
3
8
  import type {
4
9
  ArrayItem,
5
10
  ArrayPath,
@@ -10,9 +15,6 @@ import type {
10
15
  } from './path'
11
16
  import type { FormError, FormErrors } from './validation'
12
17
 
13
- export type AnyValue = S.Schema.Type<S.Schema.Any>
14
-
15
- // Wire-serialized UI knobs: ExternalApi postfix allows optional fields (Option would break the contract).
16
18
  export interface FieldLimitationsExternalApi<A = unknown> {
17
19
  readonly required?: boolean
18
20
  readonly disabled?: boolean
@@ -93,30 +95,25 @@ export interface FormView<Values, Inputs = {}> {
93
95
 
94
96
  export type InputRecord = Readonly<Record<string, AnySource>>
95
97
 
96
- // Structural `S['name']`: Source value param is invariant, so `Source<_, unknown>` erases keys to never.
97
- export type SourceName<S extends AnySource> = S['name']
98
- export type SourceValue<S> = S extends Source<string, infer A> ? A : never
99
-
100
98
  export type InputsObject<Inputs extends InputRecord> = {
101
- readonly [K in keyof Inputs as SourceName<Inputs[K]>]: SourceValue<Inputs[K]>
99
+ readonly [K in keyof Inputs as SourceName<Inputs[K]>]: ReformSourceValue<Inputs[K]>
102
100
  }
103
101
 
104
102
  export type InputStores<Inputs extends InputRecord> = {
105
- [K in keyof Inputs]: Inputs[K] extends { readonly store: Context.Tag<infer Service, AnyValue> }
103
+ [K in keyof Inputs]: Inputs[K] extends {
104
+ readonly store: Context.Tag<infer Service, infer _Store>
105
+ }
106
106
  ? Service
107
107
  : never
108
108
  }[keyof Inputs]
109
109
 
110
- export type ValuesOfSchema<S extends S.Schema.Any> = S.Schema.Encoded<S>
111
- export type DecodedOfSchema<S extends S.Schema.Any> = S.Schema.Type<S>
110
+ export type ValuesOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Encoded<Schema>
111
+ export type DecodedOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Type<Schema>
112
112
 
113
113
  export interface RuntimeConfig<Values, Decoded, Inputs> {
114
114
  readonly initial: Values
115
115
  // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob (not a serializable shape); Option would break external form definitions
116
- readonly limit?: (ctx: {
117
- readonly values: Values
118
- readonly inputs: Inputs
119
- }) => Limitations
116
+ readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
120
117
  readonly validate: (ctx: {
121
118
  readonly values: Values
122
119
  readonly decoded: Decoded
@@ -132,10 +129,7 @@ export interface RuntimeConfig<Values, Decoded, Inputs> {
132
129
  export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
133
130
  readonly initial: Values
134
131
  // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
135
- readonly limit?: (ctx: {
136
- readonly values: Values
137
- readonly inputs: Inputs
138
- }) => Limitations
132
+ readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
139
133
  // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
140
134
  readonly validate?: (ctx: {
141
135
  readonly values: Values
@@ -154,9 +148,22 @@ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
154
148
  }) => void | Effect.Effect<unknown, unknown, R>
155
149
  }
156
150
 
157
- export interface FormManifest<N extends string, Schema extends S.Schema.Any, Inputs extends InputRecord> {
151
+ export interface FormSchemaReflection {
152
+ readonly ast: S.Schema<unknown, unknown, unknown>['ast']
153
+ }
154
+
155
+ export interface FormManifestReflection<N extends string> {
158
156
  readonly kind: 'Form'
159
157
  readonly name: N
158
+ readonly schema: FormSchemaReflection
159
+ readonly inputs: InputRecord
160
+ }
161
+
162
+ export interface FormManifest<
163
+ N extends string,
164
+ Schema extends S.Schema.AnyNoContext,
165
+ Inputs extends InputRecord,
166
+ > extends FormManifestReflection<N> {
160
167
  readonly schema: Schema
161
168
  readonly inputs: Inputs
162
169
  }
@@ -170,17 +177,40 @@ export interface FormEvents {
170
177
  readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
171
178
  readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
172
179
  readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
173
- readonly move: Event.EventClass<string, { readonly path: string; readonly from: number; readonly to: number }>
174
- readonly swap: Event.EventClass<string, { readonly path: string; readonly a: number; readonly b: number }>
180
+ readonly move: Event.EventClass<
181
+ string,
182
+ { readonly path: string; readonly from: number; readonly to: number }
183
+ >
184
+ readonly swap: Event.EventClass<
185
+ string,
186
+ { readonly path: string; readonly a: number; readonly b: number }
187
+ >
188
+ }
189
+
190
+ export interface FormVisitor<Result> {
191
+ readonly visit: <
192
+ N extends string,
193
+ Schema extends S.Schema.AnyNoContext,
194
+ Inputs extends InputRecord,
195
+ >(
196
+ form: FormClass<N, Schema, Inputs>,
197
+ ) => Result
198
+ }
199
+
200
+ export interface AnyForm {
201
+ new (): {}
202
+ readonly manifest: FormManifestReflection<string>
203
+ readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
175
204
  }
176
205
 
177
206
  export interface FormClass<
178
207
  N extends string,
179
- Schema extends S.Schema.Any,
208
+ Schema extends S.Schema.AnyNoContext,
180
209
  Inputs extends InputRecord,
181
210
  > {
182
211
  new (): {}
183
212
  readonly manifest: FormManifest<N, Schema, Inputs>
213
+ readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
184
214
  readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
185
215
  readonly config: Context.Tag<
186
216
  RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
@@ -193,19 +223,13 @@ export interface FormClass<
193
223
  >
194
224
  }
195
225
 
196
- // `any` inputs wildcard: config tag is invariant via InputsObject; InputRecord would reject concrete forms.
197
- export type AnyForm = FormClass<string, S.Schema.Any, AnyValue>
198
-
199
- export type Values<F extends AnyForm> = F extends FormClass<string, infer Schema, AnyValue>
200
- ? ValuesOfSchema<Schema>
201
- : never
226
+ export type Values<F> =
227
+ F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? ValuesOfSchema<Schema> : never
202
228
 
203
- export type Decoded<F extends AnyForm> = F extends FormClass<string, infer Schema, AnyValue>
204
- ? DecodedOfSchema<Schema>
205
- : never
229
+ export type Decoded<F> =
230
+ F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? DecodedOfSchema<Schema> : never
206
231
 
207
- export type Inputs<F extends AnyForm> = F extends FormClass<string, S.Schema.Any, infer Inputs>
208
- ? InputsObject<Inputs>
209
- : never
232
+ export type Inputs<F> =
233
+ F extends FormClass<infer _Name, infer _Schema, infer Inputs> ? InputsObject<Inputs> : never
210
234
 
211
- export type View<F extends AnyForm> = FormView<Values<F>, Inputs<F>>
235
+ export type View<F> = FormView<Values<F>, Inputs<F>>
package/src/formView.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Context,
2
3
  Effect,
3
4
  Either,
4
5
  Function as Fn,
@@ -7,49 +8,54 @@ import {
7
8
  Runtime,
8
9
  Schema as S,
9
10
  } from 'effect'
11
+ import { Event, type Trigger } from '@playfast/reform'
10
12
  import {
13
+ type AnySource,
11
14
  Bus,
12
15
  CurrentTracker,
13
- Event,
16
+ type SourceIdentifier,
17
+ type SourceValue as ReformSourceValue,
14
18
  type Store,
15
- type Trigger,
16
- } from '@playfast/reform'
17
- import type { AnySource } from '@playfast/reform'
18
- import { currentArray } from './formState'
19
- import { getNestedValue, isPathOrParentDirty } from './path'
20
- import type { ArrayItem, ArrayPath, FieldPath, PathValue } from './path'
19
+ } from '@playfast/reform/internal'
20
+ import { State } from '@playfast/reform'
21
+ import {
22
+ getNestedValue,
23
+ isPathOrParentDirty,
24
+ type ArrayItem,
25
+ type ArrayPath,
26
+ type FieldPath,
27
+ type PathValue,
28
+ } from './path'
21
29
  import { errorsToRecord, firstError, routeParseError } from './validation'
30
+ import { currentArray } from './formState'
22
31
  import type {
23
- AnyForm,
24
32
  ArrayBinding,
25
- Decoded,
26
- FieldBinding,
33
+ DecodedOfSchema,
27
34
  FieldLimitations,
35
+ FieldBinding,
36
+ FormClass,
28
37
  FormState,
38
+ FormView,
29
39
  InputRecord,
30
- Inputs,
31
40
  InputsObject,
32
41
  InputStores,
33
42
  Limitations,
34
43
  RuntimeConfig,
35
- SourceValue,
36
- Values,
37
- View,
44
+ ValuesOfSchema,
38
45
  } from './formTypes'
39
46
 
40
- const readInput = <Source extends AnySource>(
41
- source: Source,
42
- ): Effect.Effect<SourceValue<Source>, never, Store<SourceValue<Source>>> =>
43
- Fn.unsafeCoerce(
44
- Effect.gen(function* () {
45
- const store = yield* source.store
46
- const tracker = yield* Effect.serviceOption(CurrentTracker)
47
- if (Option.isSome(tracker)) {
48
- tracker.value.add(store)
49
- }
50
- return store.getSnapshot()
51
- }),
47
+ const readInput = Effect.fn('forms.readInput')(function* <Input extends AnySource>(
48
+ source: Input,
49
+ ): Effect.fn.Return<ReformSourceValue<Input>, never, SourceIdentifier<Input>> {
50
+ const store = yield* Context.GenericTag<SourceIdentifier<Input>, Store<ReformSourceValue<Input>>>(
51
+ source.store.key,
52
52
  )
53
+ const tracker = yield* Effect.serviceOption(CurrentTracker)
54
+ if (Option.isSome(tracker)) {
55
+ tracker.value.add(store)
56
+ }
57
+ return store.getSnapshot()
58
+ })
53
59
 
54
60
  const readInputs = <Inputs extends InputRecord>(
55
61
  inputs: Inputs,
@@ -57,32 +63,41 @@ const readInputs = <Inputs extends InputRecord>(
57
63
  Fn.unsafeCoerce(
58
64
  Effect.gen(function* () {
59
65
  const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
60
- Effect.map(readInput(source), (snapshot) => [source.name, snapshot] as const),
66
+ Effect.map(readInput(source), (snapshot): readonly [string, unknown] => [
67
+ source.name,
68
+ snapshot,
69
+ ]),
61
70
  )
62
71
  return Record.fromEntries(pairs)
63
72
  }),
64
73
  )
65
74
 
66
- const limitationsFor = <A>(
67
- limitations: Limitations,
68
- path: string,
69
- ): FieldLimitations<A> =>
75
+ const limitationsFor = <A>(limitations: Limitations, path: string): FieldLimitations<A> =>
70
76
  Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
71
77
 
72
- const trigger = <P>(eventTrigger: Effect.Effect<Trigger<P>, never, Bus>): Effect.Effect<Trigger<P>, never, Bus> =>
73
- eventTrigger
78
+ const trigger = <P>(
79
+ eventTrigger: Effect.Effect<Trigger<P>, never, Bus>,
80
+ ): Effect.Effect<Trigger<P>, never, Bus> => eventTrigger
74
81
 
75
- // 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
76
- export const view = <F extends AnyForm>(
77
- form: F,
78
- ): Effect.Effect<View<F>, never, Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> | Bus | InputStores<F['manifest']['inputs']>> =>
82
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view preserves the exact form state/config/input requirements verbatim
83
+ export const view = <
84
+ N extends string,
85
+ Schema extends S.Schema.AnyNoContext,
86
+ FormInputs extends InputRecord,
87
+ >(
88
+ form: FormClass<N, Schema, FormInputs>,
89
+ ): Effect.Effect<
90
+ FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>>,
91
+ never,
92
+ | State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
93
+ | RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
94
+ | Bus
95
+ | InputStores<FormInputs>
96
+ > =>
79
97
  Effect.gen(function* () {
80
98
  const state = yield* form.state
81
99
  const runtimeConfig = yield* form.config
82
- // AnyForm erases inputs to `any`; restate at concrete F so R keeps declared input stores.
83
- const inputs: Inputs<F> = Fn.unsafeCoerce(
84
- yield* readInputs(Fn.unsafeCoerce<typeof form.manifest.inputs, InputRecord>(form.manifest.inputs)),
85
- )
100
+ const inputs = yield* readInputs(form.manifest.inputs)
86
101
  const limitations = Option.getOrElse(
87
102
  Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
88
103
  () => ({}),
@@ -96,7 +111,7 @@ export const view = <F extends AnyForm>(
96
111
  const swap = yield* trigger(form.events.swap.trigger)
97
112
  const runtime = yield* Effect.runtime<Bus>()
98
113
 
99
- const schema: S.Schema<Decoded<F>, Values<F>, never> = Fn.unsafeCoerce(form.manifest.schema)
114
+ const schema = form.manifest.schema
100
115
  const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
101
116
  const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
102
117
 
@@ -108,15 +123,21 @@ export const view = <F extends AnyForm>(
108
123
  }
109
124
  const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
110
125
  if (Either.isLeft(decoded)) {
111
- yield* Event.dispatch(form.events.validationFinished, { errors: routeParseError(decoded.left) })
112
- return Option.none<Decoded<F>>()
126
+ yield* Event.dispatch(form.events.validationFinished, {
127
+ errors: routeParseError(decoded.left),
128
+ })
129
+ return Option.none<DecodedOfSchema<Schema>>()
113
130
  }
114
- const custom = yield* runtimeConfig.validate({ values: state.values, decoded: decoded.right, inputs })
131
+ const custom = yield* runtimeConfig.validate({
132
+ values: state.values,
133
+ decoded: decoded.right,
134
+ inputs,
135
+ })
115
136
  const errors = errorsToRecord(custom)
116
137
  yield* Event.dispatch(form.events.validationFinished, { errors })
117
138
  return Record.keys(errors).length === 0
118
- ? Option.some(Fn.unsafeCoerce<unknown, Decoded<F>>(decoded.right))
119
- : Option.none<Decoded<F>>()
139
+ ? Option.some(decoded.right)
140
+ : Option.none<DecodedOfSchema<Schema>>()
120
141
  })
121
142
 
122
143
  const submit = () => {
@@ -128,7 +149,9 @@ export const view = <F extends AnyForm>(
128
149
  }
129
150
  yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
130
151
  yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
131
- }).pipe(Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause))),
152
+ }).pipe(
153
+ Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause)),
154
+ ),
132
155
  )
133
156
  }
134
157
 
@@ -136,16 +159,21 @@ export const view = <F extends AnyForm>(
136
159
  Runtime.runFork(runtime)(runValidation(false))
137
160
  }
138
161
 
139
- const field = <P extends FieldPath<Values<F>>>(path: P): FieldBinding<PathValue<Values<F>, P>> => {
140
- const fieldValue: PathValue<Values<F>, P> = Fn.unsafeCoerce(getNestedValue(state.values, path))
162
+ const field = <P extends FieldPath<ValuesOfSchema<Schema>>>(
163
+ path: P,
164
+ ): FieldBinding<PathValue<ValuesOfSchema<Schema>, P>> => {
165
+ const fieldValue: PathValue<ValuesOfSchema<Schema>, P> = Fn.unsafeCoerce(
166
+ getNestedValue(state.values, path),
167
+ )
141
168
  return {
142
169
  path,
143
170
  value: fieldValue,
144
171
  set: (next) => {
145
172
  if (typeof next === 'function') {
146
- const updater = Fn.unsafeCoerce<typeof next, (prev: PathValue<Values<F>, P>) => PathValue<Values<F>, P>>(
147
- next,
148
- )
173
+ const updater = Fn.unsafeCoerce<
174
+ typeof next,
175
+ (prev: PathValue<ValuesOfSchema<Schema>, P>) => PathValue<ValuesOfSchema<Schema>, P>
176
+ >(next)
149
177
  setField({ path, value: updater(fieldValue) })
150
178
  return
151
179
  }
@@ -156,12 +184,14 @@ export const view = <F extends AnyForm>(
156
184
  dirty: isPathOrParentDirty(state.dirtyPaths, path),
157
185
  touched: state.touched[path] === true,
158
186
  validating: false,
159
- limitations: limitationsFor<PathValue<Values<F>, P>>(limitations, path),
187
+ limitations: limitationsFor<PathValue<ValuesOfSchema<Schema>, P>>(limitations, path),
160
188
  }
161
189
  }
162
190
 
163
- const array = <P extends ArrayPath<Values<F>>>(path: P): ArrayBinding<ArrayItem<Values<F>, P>> => {
164
- const arrayValues: ReadonlyArray<ArrayItem<Values<F>, P>> = Fn.unsafeCoerce(
191
+ const array = <P extends ArrayPath<ValuesOfSchema<Schema>>>(
192
+ path: P,
193
+ ): ArrayBinding<ArrayItem<ValuesOfSchema<Schema>, P>> => {
194
+ const arrayValues: ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>> = Fn.unsafeCoerce(
165
195
  currentArray({ source: state.values, path }),
166
196
  )
167
197
  const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
@@ -178,11 +208,14 @@ export const view = <F extends AnyForm>(
178
208
  remove: (index) => remove({ path, index }),
179
209
  move: (from, to) => move({ path, from, to }),
180
210
  swap: (first, second) => swap({ path, a: first, b: second }),
181
- limitations: limitationsFor<ReadonlyArray<ArrayItem<Values<F>, P>>>(limitations, path),
211
+ limitations: limitationsFor<ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>>>(
212
+ limitations,
213
+ path,
214
+ ),
182
215
  }
183
216
  }
184
217
 
185
- const formView: View<F> = Fn.unsafeCoerce({
218
+ const formView: FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>> = Fn.unsafeCoerce({
186
219
  values: state.values,
187
220
  inputs,
188
221
  errors: state.errors,
@@ -200,3 +233,4 @@ export const view = <F extends AnyForm>(
200
233
  })
201
234
  return formView
202
235
  })
236
+
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
@@ -24,52 +24,48 @@ type IsTagged<T> = T extends Tagged ? true : false
24
24
  export type FieldPath<T> = T extends Primitive
25
25
  ? never
26
26
  : {
27
- readonly [K in StringKey<T>]:
28
- T[K] extends ReadonlyArray<unknown>
27
+ readonly [K in StringKey<T>]: T[K] extends ReadonlyArray<unknown>
28
+ ? never
29
+ : IsTagged<T[K]> extends true
29
30
  ? never
30
- : IsTagged<T[K]> extends true
31
- ? never
32
- : T[K] extends Primitive
33
- ? K
34
- : T[K] extends object
35
- ? K | `${K}.${FieldPath<T[K]>}`
36
- : K
31
+ : T[K] extends Primitive
32
+ ? K
33
+ : T[K] extends object
34
+ ? K | `${K}.${FieldPath<T[K]>}`
35
+ : K
37
36
  }[StringKey<T>]
38
37
 
39
38
  export type ArrayPath<T> = T extends Primitive
40
39
  ? never
41
40
  : {
42
- readonly [K in StringKey<T>]:
43
- T[K] extends ReadonlyArray<unknown>
44
- ? K
45
- : IsTagged<T[K]> extends true
46
- ? never
47
- : T[K] extends object
48
- ? `${K}.${ArrayPath<T[K]>}`
49
- : 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
50
48
  }[StringKey<T>]
51
49
 
52
50
  export type VariantPath<T> = T extends Primitive
53
51
  ? never
54
52
  : {
55
- readonly [K in StringKey<T>]:
56
- IsTagged<T[K]> extends true
57
- ? K
58
- : T[K] extends ReadonlyArray<unknown>
59
- ? never
60
- : T[K] extends object
61
- ? `${K}.${VariantPath<T[K]>}`
62
- : 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
63
60
  }[StringKey<T>]
64
61
 
65
- export type PathValue<T, P extends string> =
66
- P extends `${infer Head}.${infer Tail}`
67
- ? Head extends keyof T
68
- ? PathValue<T[Head], Tail>
69
- : never
70
- : P extends keyof T
71
- ? T[P]
72
- : 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
73
69
 
74
70
  export type ArrayItem<T, P extends string> =
75
71
  PathValue<T, P> extends ReadonlyArray<infer Item> ? Item : never
@@ -196,7 +192,9 @@ export const recalculateDirtyPaths = (
196
192
  ) {
197
193
  const currentRecord = asRecord(current)
198
194
  const originalRecord = asRecord(original)
199
- 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
+ )
200
198
  return keys.flatMap((key) =>
201
199
  visit({
202
200
  current: currentRecord[key],