@lucas-barake/effect-form 0.1.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.
Files changed (64) hide show
  1. package/Form/package.json +6 -0
  2. package/FormAtoms/package.json +6 -0
  3. package/LICENSE +21 -0
  4. package/Mode/package.json +6 -0
  5. package/README.md +5 -0
  6. package/Validation/package.json +6 -0
  7. package/dist/cjs/Form.js +299 -0
  8. package/dist/cjs/Form.js.map +1 -0
  9. package/dist/cjs/FormAtoms.js +266 -0
  10. package/dist/cjs/FormAtoms.js.map +1 -0
  11. package/dist/cjs/Mode.js +64 -0
  12. package/dist/cjs/Mode.js.map +1 -0
  13. package/dist/cjs/Validation.js +69 -0
  14. package/dist/cjs/Validation.js.map +1 -0
  15. package/dist/cjs/index.js +35 -0
  16. package/dist/cjs/index.js.map +1 -0
  17. package/dist/cjs/internal/dirty.js +101 -0
  18. package/dist/cjs/internal/dirty.js.map +1 -0
  19. package/dist/cjs/internal/path.js +96 -0
  20. package/dist/cjs/internal/path.js.map +1 -0
  21. package/dist/cjs/internal/weak-registry.js +52 -0
  22. package/dist/cjs/internal/weak-registry.js.map +1 -0
  23. package/dist/dts/Form.d.ts +317 -0
  24. package/dist/dts/Form.d.ts.map +1 -0
  25. package/dist/dts/FormAtoms.d.ts +145 -0
  26. package/dist/dts/FormAtoms.d.ts.map +1 -0
  27. package/dist/dts/Mode.d.ts +55 -0
  28. package/dist/dts/Mode.d.ts.map +1 -0
  29. package/dist/dts/Validation.d.ts +23 -0
  30. package/dist/dts/Validation.d.ts.map +1 -0
  31. package/dist/dts/index.d.ts +26 -0
  32. package/dist/dts/index.d.ts.map +1 -0
  33. package/dist/dts/internal/dirty.d.ts +13 -0
  34. package/dist/dts/internal/dirty.d.ts.map +1 -0
  35. package/dist/dts/internal/path.d.ts +32 -0
  36. package/dist/dts/internal/path.d.ts.map +1 -0
  37. package/dist/dts/internal/weak-registry.d.ts +7 -0
  38. package/dist/dts/internal/weak-registry.d.ts.map +1 -0
  39. package/dist/esm/Form.js +263 -0
  40. package/dist/esm/Form.js.map +1 -0
  41. package/dist/esm/FormAtoms.js +238 -0
  42. package/dist/esm/FormAtoms.js.map +1 -0
  43. package/dist/esm/Mode.js +36 -0
  44. package/dist/esm/Mode.js.map +1 -0
  45. package/dist/esm/Validation.js +40 -0
  46. package/dist/esm/Validation.js.map +1 -0
  47. package/dist/esm/index.js +26 -0
  48. package/dist/esm/index.js.map +1 -0
  49. package/dist/esm/internal/dirty.js +72 -0
  50. package/dist/esm/internal/dirty.js.map +1 -0
  51. package/dist/esm/internal/path.js +86 -0
  52. package/dist/esm/internal/path.js.map +1 -0
  53. package/dist/esm/internal/weak-registry.js +45 -0
  54. package/dist/esm/internal/weak-registry.js.map +1 -0
  55. package/dist/esm/package.json +4 -0
  56. package/package.json +64 -0
  57. package/src/Form.ts +522 -0
  58. package/src/FormAtoms.ts +485 -0
  59. package/src/Mode.ts +59 -0
  60. package/src/Validation.ts +43 -0
  61. package/src/index.ts +28 -0
  62. package/src/internal/dirty.ts +96 -0
  63. package/src/internal/path.ts +93 -0
  64. package/src/internal/weak-registry.ts +60 -0
package/src/Form.ts ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+ import type * as Effect from "effect/Effect"
5
+ import * as Predicate from "effect/Predicate"
6
+ import * as Schema from "effect/Schema"
7
+
8
+ /**
9
+ * Unique identifier for FormBuilder instances.
10
+ *
11
+ * @since 1.0.0
12
+ * @category Symbols
13
+ */
14
+ export const TypeId: unique symbol = Symbol.for("@lucas-barake/effect-form/Form")
15
+
16
+ /**
17
+ * @since 1.0.0
18
+ * @category Symbols
19
+ */
20
+ export type TypeId = typeof TypeId
21
+
22
+ /**
23
+ * Unique identifier for Field references.
24
+ *
25
+ * @since 1.0.0
26
+ * @category Symbols
27
+ * @internal
28
+ */
29
+ export const FieldTypeId: unique symbol = Symbol.for("@lucas-barake/effect-form/Field")
30
+
31
+ /**
32
+ * @since 1.0.0
33
+ * @category Symbols
34
+ * @internal
35
+ */
36
+ export type FieldTypeId = typeof FieldTypeId
37
+
38
+ /**
39
+ * A field reference carrying type and path info for type-safe setValue operations.
40
+ *
41
+ * @since 1.0.0
42
+ * @category Models
43
+ */
44
+ export interface Field<S> {
45
+ readonly [FieldTypeId]: FieldTypeId
46
+ readonly _S: S
47
+ readonly key: string
48
+ }
49
+
50
+ /**
51
+ * Creates a field reference for type-safe setValue operations.
52
+ *
53
+ * @since 1.0.0
54
+ * @category Constructors
55
+ * @internal
56
+ */
57
+ export const makeFieldRef = <S>(key: string): Field<S> => ({
58
+ [FieldTypeId]: FieldTypeId,
59
+ _S: undefined as any,
60
+ key,
61
+ })
62
+
63
+ /**
64
+ * A scalar field definition containing the key and schema.
65
+ *
66
+ * @since 1.0.0
67
+ * @category Models
68
+ */
69
+ export interface FieldDef<K extends string, S extends Schema.Schema.Any> {
70
+ readonly _tag: "field"
71
+ readonly key: K
72
+ readonly schema: S
73
+ }
74
+
75
+ /**
76
+ * An array field definition containing a schema for items.
77
+ *
78
+ * @since 1.0.0
79
+ * @category Models
80
+ */
81
+ export interface ArrayFieldDef<K extends string, S extends Schema.Schema.Any> {
82
+ readonly _tag: "array"
83
+ readonly key: K
84
+ readonly itemSchema: S
85
+ }
86
+
87
+ /**
88
+ * Union of all field definition types.
89
+ *
90
+ * @since 1.0.0
91
+ * @category Models
92
+ */
93
+ export type AnyFieldDef = FieldDef<string, Schema.Schema.Any> | ArrayFieldDef<string, Schema.Schema.Any>
94
+
95
+ /**
96
+ * Creates a scalar field definition.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const NameField = Form.makeField("name", Schema.String)
101
+ * const form = Form.empty.addField(NameField)
102
+ * ```
103
+ *
104
+ * @since 1.0.0
105
+ * @category Constructors
106
+ */
107
+ export const makeField = <K extends string, S extends Schema.Schema.Any>(
108
+ key: K,
109
+ schema: S,
110
+ ): FieldDef<K, S> => ({
111
+ _tag: "field",
112
+ key,
113
+ schema,
114
+ })
115
+
116
+ /**
117
+ * Creates an array field definition.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * // Array of primitives
122
+ * const TagsField = Form.makeArrayField("tags", Schema.String)
123
+ *
124
+ * // Array of objects
125
+ * const ItemsField = Form.makeArrayField("items", Schema.Struct({
126
+ * name: Schema.String,
127
+ * quantity: Schema.Number
128
+ * }))
129
+ * ```
130
+ *
131
+ * @since 1.0.0
132
+ * @category Constructors
133
+ */
134
+ export const makeArrayField = <K extends string, S extends Schema.Schema.Any>(
135
+ key: K,
136
+ itemSchema: S,
137
+ ): ArrayFieldDef<K, S> => ({
138
+ _tag: "array",
139
+ key,
140
+ itemSchema,
141
+ })
142
+
143
+ /**
144
+ * A record of field definitions.
145
+ *
146
+ * @since 1.0.0
147
+ * @category Models
148
+ */
149
+ export type FieldsRecord = Record<string, AnyFieldDef>
150
+
151
+ /**
152
+ * Extracts the encoded (input) type from a fields record.
153
+ *
154
+ * @since 1.0.0
155
+ * @category Type Helpers
156
+ */
157
+ export type EncodedFromFields<T extends FieldsRecord> = {
158
+ readonly [K in keyof T]: T[K] extends FieldDef<any, infer S> ? Schema.Schema.Encoded<S>
159
+ : T[K] extends ArrayFieldDef<any, infer S> ? ReadonlyArray<Schema.Schema.Encoded<S>>
160
+ : never
161
+ }
162
+
163
+ /**
164
+ * Extracts the decoded (output) type from a fields record.
165
+ *
166
+ * @since 1.0.0
167
+ * @category Type Helpers
168
+ */
169
+ export type DecodedFromFields<T extends FieldsRecord> = {
170
+ readonly [K in keyof T]: T[K] extends FieldDef<any, infer S> ? Schema.Schema.Type<S>
171
+ : T[K] extends ArrayFieldDef<any, infer S> ? ReadonlyArray<Schema.Schema.Type<S>>
172
+ : never
173
+ }
174
+
175
+ /**
176
+ * The state of a form at runtime.
177
+ *
178
+ * @since 1.0.0
179
+ * @category Models
180
+ */
181
+ export interface FormState<TFields extends FieldsRecord> {
182
+ readonly values: EncodedFromFields<TFields>
183
+ readonly initialValues: EncodedFromFields<TFields>
184
+ readonly touched: { readonly [K in keyof TFields]: boolean }
185
+ readonly submitCount: number
186
+ readonly dirtyFields: ReadonlySet<string>
187
+ }
188
+
189
+ interface SyncRefinement {
190
+ readonly _tag: "sync"
191
+ readonly fn: (values: unknown) => Schema.FilterOutput
192
+ }
193
+
194
+ interface AsyncRefinement {
195
+ readonly _tag: "async"
196
+ readonly fn: (values: unknown) => Effect.Effect<Schema.FilterOutput, never, unknown>
197
+ }
198
+
199
+ type Refinement = SyncRefinement | AsyncRefinement
200
+
201
+ /**
202
+ * A builder for constructing type-safe forms with Effect Schema validation.
203
+ *
204
+ * **Details**
205
+ *
206
+ * FormBuilder uses a fluent API pattern to define form fields. Each field
207
+ * includes a Schema for validation. The builder accumulates field definitions
208
+ * and context requirements (`R`) from schemas that use Effect services.
209
+ *
210
+ * @since 1.0.0
211
+ * @category Models
212
+ */
213
+ export interface FormBuilder<TFields extends FieldsRecord, R> {
214
+ readonly [TypeId]: TypeId
215
+ readonly fields: TFields
216
+ readonly refinements: ReadonlyArray<Refinement>
217
+ readonly _R?: R
218
+
219
+ /**
220
+ * Adds a scalar field to the form builder.
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * const NameField = Form.makeField("name", Schema.String)
225
+ * const form = Form.empty.addField(NameField)
226
+ * ```
227
+ */
228
+ addField<K extends string, S extends Schema.Schema.Any>(
229
+ this: FormBuilder<TFields, R>,
230
+ field: FieldDef<K, S>,
231
+ ): FormBuilder<TFields & { readonly [key in K]: FieldDef<K, S> }, R | Schema.Schema.Context<S>>
232
+
233
+ /**
234
+ * Adds an array field to the form builder.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * const ItemsField = Form.makeArrayField("items", Schema.Struct({ name: Schema.String }))
239
+ * const form = Form.empty.addField(ItemsField)
240
+ * ```
241
+ */
242
+ addField<K extends string, S extends Schema.Schema.Any>(
243
+ this: FormBuilder<TFields, R>,
244
+ field: ArrayFieldDef<K, S>,
245
+ ): FormBuilder<TFields & { readonly [key in K]: ArrayFieldDef<K, S> }, R | Schema.Schema.Context<S>>
246
+
247
+ /**
248
+ * Merges another FormBuilder's fields into this one.
249
+ * Useful for composing reusable field groups.
250
+ *
251
+ * @example
252
+ * ```ts
253
+ * const addressFields = Form.empty
254
+ * .addField("street", Schema.String)
255
+ * .addField("city", Schema.String)
256
+ *
257
+ * const userForm = Form.empty
258
+ * .addField("name", Schema.String)
259
+ * .merge(addressFields)
260
+ * ```
261
+ */
262
+ merge<TFields2 extends FieldsRecord, R2>(
263
+ this: FormBuilder<TFields, R>,
264
+ other: FormBuilder<TFields2, R2>,
265
+ ): FormBuilder<TFields & TFields2, R | R2>
266
+
267
+ /**
268
+ * Adds a synchronous cross-field validation refinement to the form.
269
+ *
270
+ * @example
271
+ * ```ts
272
+ * const form = Form.empty
273
+ * .addField("password", Schema.String)
274
+ * .addField("confirmPassword", Schema.String)
275
+ * .refine((values) => {
276
+ * if (values.password !== values.confirmPassword) {
277
+ * return { path: ["confirmPassword"], message: "Passwords must match" }
278
+ * }
279
+ * })
280
+ * ```
281
+ */
282
+ refine(
283
+ this: FormBuilder<TFields, R>,
284
+ predicate: (values: DecodedFromFields<TFields>) => Schema.FilterOutput,
285
+ ): FormBuilder<TFields, R>
286
+
287
+ /**
288
+ * Adds an asynchronous cross-field validation refinement to the form.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * const form = Form.empty
293
+ * .addField("username", Schema.String)
294
+ * .refineEffect((values) =>
295
+ * Effect.gen(function* () {
296
+ * const taken = yield* checkUsername(values.username)
297
+ * if (taken) return { path: ["username"], message: "Already taken" }
298
+ * })
299
+ * )
300
+ * ```
301
+ */
302
+ refineEffect<RD>(
303
+ this: FormBuilder<TFields, R>,
304
+ predicate: (values: DecodedFromFields<TFields>) => Effect.Effect<Schema.FilterOutput, never, RD>,
305
+ ): FormBuilder<TFields, R | RD>
306
+ }
307
+
308
+ const FormBuilderProto = {
309
+ [TypeId]: TypeId,
310
+ addField<TFields extends FieldsRecord, R>(
311
+ this: FormBuilder<TFields, R>,
312
+ field: AnyFieldDef,
313
+ ): FormBuilder<any, any> {
314
+ const newSelf = Object.create(FormBuilderProto)
315
+ newSelf.fields = { ...this.fields, [field.key]: field }
316
+ newSelf.refinements = this.refinements
317
+ return newSelf
318
+ },
319
+ merge<TFields extends FieldsRecord, R, TFields2 extends FieldsRecord, R2>(
320
+ this: FormBuilder<TFields, R>,
321
+ other: FormBuilder<TFields2, R2>,
322
+ ): FormBuilder<TFields & TFields2, R | R2> {
323
+ const newSelf = Object.create(FormBuilderProto)
324
+ newSelf.fields = { ...this.fields, ...other.fields }
325
+ newSelf.refinements = [...this.refinements, ...other.refinements]
326
+ return newSelf
327
+ },
328
+ refine<TFields extends FieldsRecord, R>(
329
+ this: FormBuilder<TFields, R>,
330
+ predicate: (values: DecodedFromFields<TFields>) => Schema.FilterOutput,
331
+ ): FormBuilder<TFields, R> {
332
+ const newSelf = Object.create(FormBuilderProto)
333
+ newSelf.fields = this.fields
334
+ newSelf.refinements = [
335
+ ...this.refinements,
336
+ { _tag: "sync" as const, fn: (values: unknown) => predicate(values as DecodedFromFields<TFields>) },
337
+ ]
338
+ return newSelf
339
+ },
340
+ refineEffect<TFields extends FieldsRecord, R, RD>(
341
+ this: FormBuilder<TFields, R>,
342
+ predicate: (values: DecodedFromFields<TFields>) => Effect.Effect<Schema.FilterOutput, never, RD>,
343
+ ): FormBuilder<TFields, R | RD> {
344
+ const newSelf = Object.create(FormBuilderProto)
345
+ newSelf.fields = this.fields
346
+ newSelf.refinements = [
347
+ ...this.refinements,
348
+ { _tag: "async" as const, fn: (values: unknown) => predicate(values as DecodedFromFields<TFields>) },
349
+ ]
350
+ return newSelf
351
+ },
352
+ }
353
+
354
+ /**
355
+ * Checks if a value is a `FormBuilder`.
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * import * as Form from "@lucas-barake/effect-form"
360
+ *
361
+ * const builder = Form.empty
362
+ *
363
+ * console.log(Form.isFormBuilder(builder))
364
+ * // Output: true
365
+ *
366
+ * console.log(Form.isFormBuilder({}))
367
+ * // Output: false
368
+ * ```
369
+ *
370
+ * @since 1.0.0
371
+ * @category Guards
372
+ */
373
+ export const isFormBuilder = (u: unknown): u is FormBuilder<any, any> => Predicate.hasProperty(u, TypeId)
374
+
375
+ /**
376
+ * Checks if a field definition is an array field.
377
+ *
378
+ * @since 1.0.0
379
+ * @category Guards
380
+ */
381
+ export const isArrayFieldDef = (def: AnyFieldDef): def is ArrayFieldDef<string, any> => def._tag === "array"
382
+
383
+ /**
384
+ * Checks if a field definition is a simple field.
385
+ *
386
+ * @since 1.0.0
387
+ * @category Guards
388
+ */
389
+ export const isFieldDef = (def: AnyFieldDef): def is FieldDef<string, Schema.Schema.Any> => def._tag === "field"
390
+
391
+ /**
392
+ * Gets a default encoded value from a schema.
393
+ *
394
+ * @since 1.0.0
395
+ * @category Helpers
396
+ */
397
+ export const getDefaultFromSchema = (schema: Schema.Schema.Any): unknown => {
398
+ const ast = schema.ast
399
+ switch (ast._tag) {
400
+ case "StringKeyword":
401
+ case "TemplateLiteral":
402
+ return ""
403
+ case "NumberKeyword":
404
+ return 0
405
+ case "BooleanKeyword":
406
+ return false
407
+ case "TypeLiteral": {
408
+ const result: Record<string, unknown> = {}
409
+ for (const prop of ast.propertySignatures) {
410
+ result[prop.name as string] = getDefaultFromSchema(Schema.make(prop.type))
411
+ }
412
+ return result
413
+ }
414
+ case "Transformation":
415
+ return getDefaultFromSchema(Schema.make(ast.from))
416
+ case "Refinement":
417
+ return getDefaultFromSchema(Schema.make(ast.from))
418
+ case "Suspend":
419
+ return getDefaultFromSchema(Schema.make(ast.f()))
420
+ default:
421
+ return ""
422
+ }
423
+ }
424
+
425
+ /**
426
+ * An empty `FormBuilder` to start building a form.
427
+ *
428
+ * **Details**
429
+ *
430
+ * This is the entry point for building a form. Use method chaining to add
431
+ * fields and then build the form with a React adapter.
432
+ *
433
+ * @example
434
+ * ```ts
435
+ * import * as Form from "@lucas-barake/effect-form"
436
+ * import * as Schema from "effect/Schema"
437
+ *
438
+ * const EmailField = Form.makeField("email", Schema.String)
439
+ * const PasswordField = Form.makeField("password", Schema.String)
440
+ *
441
+ * const loginForm = Form.empty
442
+ * .addField(EmailField)
443
+ * .addField(PasswordField)
444
+ * ```
445
+ *
446
+ * @since 1.0.0
447
+ * @category Constructors
448
+ */
449
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
450
+ export const empty: FormBuilder<{}, never> = (() => {
451
+ const self = Object.create(FormBuilderProto)
452
+ self.fields = {}
453
+ self.refinements = []
454
+ return self
455
+ })()
456
+
457
+ /**
458
+ * Builds a combined Schema from a FormBuilder's field definitions.
459
+ *
460
+ * @since 1.0.0
461
+ * @category Schema
462
+ */
463
+ export const buildSchema = <TFields extends FieldsRecord, R>(
464
+ self: FormBuilder<TFields, R>,
465
+ ): Schema.Schema<DecodedFromFields<TFields>, EncodedFromFields<TFields>, R> => {
466
+ const schemaFields: Record<string, Schema.Schema.Any> = {}
467
+ for (const [key, def] of Object.entries(self.fields)) {
468
+ if (isArrayFieldDef(def)) {
469
+ schemaFields[key] = Schema.Array(def.itemSchema)
470
+ } else if (isFieldDef(def)) {
471
+ schemaFields[key] = def.schema
472
+ }
473
+ }
474
+
475
+ let schema: Schema.Schema<any, any, any> = Schema.Struct(schemaFields)
476
+
477
+ for (const refinement of self.refinements) {
478
+ if (refinement._tag === "sync") {
479
+ schema = schema.pipe(Schema.filter(refinement.fn))
480
+ } else {
481
+ schema = schema.pipe(Schema.filterEffect(refinement.fn))
482
+ }
483
+ }
484
+
485
+ return schema as Schema.Schema<
486
+ DecodedFromFields<TFields>,
487
+ EncodedFromFields<TFields>,
488
+ R
489
+ >
490
+ }
491
+
492
+ /**
493
+ * Gets default encoded values for a fields record.
494
+ *
495
+ * @since 1.0.0
496
+ * @category Helpers
497
+ */
498
+ export const getDefaultEncodedValues = (fields: FieldsRecord): Record<string, unknown> => {
499
+ const result: Record<string, unknown> = {}
500
+ for (const [key, def] of Object.entries(fields)) {
501
+ if (isArrayFieldDef(def)) {
502
+ result[key] = []
503
+ } else {
504
+ result[key] = ""
505
+ }
506
+ }
507
+ return result
508
+ }
509
+
510
+ /**
511
+ * Creates a touched record with all fields set to the given value.
512
+ *
513
+ * @since 1.0.0
514
+ * @category Helpers
515
+ */
516
+ export const createTouchedRecord = (fields: FieldsRecord, value: boolean): Record<string, boolean> => {
517
+ const result: Record<string, boolean> = {}
518
+ for (const key of Object.keys(fields)) {
519
+ result[key] = value
520
+ }
521
+ return result
522
+ }