@zeno-lib/forms 0.7.1 → 0.9.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.
@@ -3,20 +3,34 @@
3
3
 
4
4
  import type {
5
5
  AnyFormApi,
6
+ AppFieldExtendedReactFieldGroupApi,
7
+ AppFieldExtendedReactFormApi,
8
+ BaseFormOptions,
6
9
  DeepKeys,
7
10
  DeepValue,
11
+ FieldsMap,
8
12
  FormAsyncValidateOrFn,
9
13
  FormOptions,
10
14
  FormValidateOrFn,
15
+ WithFormProps,
11
16
  } from "@tanstack/react-form"
12
17
  import { createFormHook } from "@tanstack/react-form"
13
- import { type ComponentProps, type ReactNode, useMemo } from "react"
18
+ import {
19
+ type ComponentProps,
20
+ type PropsWithChildren,
21
+ type ReactNode,
22
+ useMemo,
23
+ } from "react"
14
24
  import { applyValidationError } from "./lib/apply-validation-error"
15
25
  import { fieldContext, formContext } from "./lib/contexts"
16
26
  import { withDefaultProps } from "./lib/default-props"
17
27
  import { withEmptyStartValues } from "./lib/empty-value"
18
28
  import { useDisabledOption } from "./lib/form-disabled"
19
- import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
29
+ import {
30
+ deepMergeDefaults,
31
+ extractZodDefaults,
32
+ pickSchemaShape,
33
+ } from "./lib/schema-defaults"
20
34
  import {
21
35
  getRequiredPaths,
22
36
  type StandardSchemaLike,
@@ -390,42 +404,211 @@ export function createZenoForm<
390
404
  config.formComponents,
391
405
  config.defaultProps
392
406
  )
393
- const { useAppForm, withFieldGroup, withForm } = createFormHook({
407
+ const {
408
+ useAppForm,
409
+ withFieldGroup: withTanStackFieldGroup,
410
+ withForm: withTanStackForm,
411
+ } = createFormHook({
394
412
  fieldComponents,
395
413
  fieldContext,
396
414
  formComponents,
397
415
  formContext,
398
416
  })
399
417
 
400
- function useAppFields<TForm extends AnyAppForm>(form: TForm) {
401
- type T = FormDataOf<TForm>
402
- return useMemo(() => {
403
- const Field = form.AppField as React.ComponentType<{
418
+ // One set of named fields per form, shared by `useForm`, `useAppFields` and
419
+ // every section and group that renders the form.
420
+ const fieldsByForm = new WeakMap<object, Record<string, AnyComponent>>()
421
+
422
+ function getAppFields<TForm extends AnyAppForm>(form: TForm) {
423
+ type Fields = FieldWrappers<FC, FormDataOf<TForm>>
424
+ const cached = fieldsByForm.get(form)
425
+ if (cached !== undefined) {
426
+ return cached as Fields
427
+ }
428
+ const Field = form.AppField as React.ComponentType<{
429
+ name: string
430
+ validators?: AnyFieldValidators
431
+ listeners?: AnyFieldListeners
432
+ children: () => ReactNode
433
+ }>
434
+ const wrappers: Record<string, AnyComponent> = {}
435
+ for (const key of Object.keys(fieldComponents)) {
436
+ const Impl = fieldComponents[key] as AnyComponent
437
+ wrappers[key] = ({
438
+ name,
439
+ validators,
440
+ listeners,
441
+ ...props
442
+ }: {
404
443
  name: string
405
444
  validators?: AnyFieldValidators
406
445
  listeners?: AnyFieldListeners
407
- children: () => ReactNode
408
- }>
409
- const wrappers: Record<string, AnyComponent> = {}
410
- for (const key of Object.keys(fieldComponents)) {
411
- const Impl = fieldComponents[key] as AnyComponent
412
- wrappers[key] = ({
413
- name,
414
- validators,
415
- listeners,
416
- ...props
417
- }: {
418
- name: string
419
- validators?: AnyFieldValidators
420
- listeners?: AnyFieldListeners
421
- }) => (
422
- <Field listeners={listeners} name={name} validators={validators}>
423
- {() => <Impl {...props} />}
424
- </Field>
425
- )
446
+ }) => (
447
+ <Field listeners={listeners} name={name} validators={validators}>
448
+ {() => <Impl {...props} />}
449
+ </Field>
450
+ )
451
+ }
452
+ fieldsByForm.set(form, wrappers)
453
+ return wrappers as Fields
454
+ }
455
+
456
+ function useAppFields<TForm extends AnyAppForm>(form: TForm) {
457
+ return getAppFields(form)
458
+ }
459
+
460
+ // Puts the named fields on `target` (a section's form, a group) where it has
461
+ // none of that name. The same fields every time, so running it on each
462
+ // render changes nothing after the first.
463
+ function withNamedFields<T extends AnyAppForm>(target: T): T {
464
+ for (const [name, field] of Object.entries(getAppFields(target))) {
465
+ if (!(name in target)) {
466
+ Object.assign(target, { [name]: field })
426
467
  }
427
- return wrappers as FieldWrappers<FC, T>
428
- }, [form])
468
+ }
469
+ return target
470
+ }
471
+
472
+ /**
473
+ * TanStack's `withForm`, whose `render` gets a `form` carrying the typed
474
+ * fields, as `useForm`'s does: `<form.InputField name="email" />`.
475
+ */
476
+ function withForm<
477
+ TFormData,
478
+ TOnMount extends SyncValidator<TFormData>,
479
+ TOnChange extends SyncValidator<TFormData>,
480
+ TOnChangeAsync extends AsyncValidator<TFormData>,
481
+ TOnBlur extends SyncValidator<TFormData>,
482
+ TOnBlurAsync extends AsyncValidator<TFormData>,
483
+ TOnSubmit extends SyncValidator<TFormData>,
484
+ TOnSubmitAsync extends AsyncValidator<TFormData>,
485
+ TOnDynamic extends SyncValidator<TFormData>,
486
+ TOnDynamicAsync extends AsyncValidator<TFormData>,
487
+ TOnServer extends AsyncValidator<TFormData>,
488
+ TSubmitMeta,
489
+ // biome-ignore lint/complexity/noBannedTypes: TanStack's own default, so a section without `props` infers as it does.
490
+ TRenderProps extends object = {},
491
+ >({
492
+ render,
493
+ ...options
494
+ }: Omit<
495
+ WithFormProps<
496
+ TFormData,
497
+ TOnMount,
498
+ TOnChange,
499
+ TOnChangeAsync,
500
+ TOnBlur,
501
+ TOnBlurAsync,
502
+ TOnSubmit,
503
+ TOnSubmitAsync,
504
+ TOnDynamic,
505
+ TOnDynamicAsync,
506
+ TOnServer,
507
+ TSubmitMeta,
508
+ FC,
509
+ FMC,
510
+ TRenderProps
511
+ >,
512
+ "render"
513
+ > & {
514
+ render: (
515
+ props: PropsWithChildren<
516
+ NoInfer<TRenderProps> & {
517
+ // The named fields ride in the form components' slot, so the type
518
+ // stays TanStack's and a nested `withFieldGroup` still infers it.
519
+ form: AppFieldExtendedReactFormApi<
520
+ TFormData,
521
+ TOnMount,
522
+ TOnChange,
523
+ TOnChangeAsync,
524
+ TOnBlur,
525
+ TOnBlurAsync,
526
+ TOnSubmit,
527
+ TOnSubmitAsync,
528
+ TOnDynamic,
529
+ TOnDynamicAsync,
530
+ TOnServer,
531
+ TSubmitMeta,
532
+ FC,
533
+ FMC & FieldWrappers<FC, TFormData>
534
+ >
535
+ }
536
+ >
537
+ ) => ReactNode
538
+ }) {
539
+ return withTanStackForm<
540
+ TFormData,
541
+ TOnMount,
542
+ TOnChange,
543
+ TOnChangeAsync,
544
+ TOnBlur,
545
+ TOnBlurAsync,
546
+ TOnSubmit,
547
+ TOnSubmitAsync,
548
+ TOnDynamic,
549
+ TOnDynamicAsync,
550
+ TOnServer,
551
+ TSubmitMeta,
552
+ TRenderProps
553
+ >({
554
+ ...options,
555
+ render: (props) =>
556
+ render({
557
+ ...props,
558
+ form: withNamedFields(props.form),
559
+ } as Parameters<typeof render>[0]),
560
+ })
561
+ }
562
+
563
+ /**
564
+ * TanStack's `withFieldGroup`, whose `render` gets a `group` carrying the
565
+ * typed fields of the group's own values, as `useForm`'s form does:
566
+ * `<group.InputField name="street" />`.
567
+ */
568
+ function withFieldGroup<
569
+ TFieldGroupData,
570
+ TSubmitMeta,
571
+ // biome-ignore lint/complexity/noBannedTypes: TanStack's own default, so a group without `props` infers as it does.
572
+ TRenderProps extends object = {},
573
+ >({
574
+ render,
575
+ ...options
576
+ }: BaseFormOptions<TFieldGroupData, TSubmitMeta> & {
577
+ props?: TRenderProps
578
+ render: (
579
+ props: PropsWithChildren<
580
+ NoInfer<TRenderProps> & {
581
+ // As `withForm`'s, with TanStack's own arguments for the rest.
582
+ group: AppFieldExtendedReactFieldGroupApi<
583
+ unknown,
584
+ TFieldGroupData,
585
+ string | FieldsMap<unknown, TFieldGroupData>,
586
+ undefined | FormValidateOrFn<unknown>,
587
+ undefined | FormValidateOrFn<unknown>,
588
+ undefined | FormAsyncValidateOrFn<unknown>,
589
+ undefined | FormValidateOrFn<unknown>,
590
+ undefined | FormAsyncValidateOrFn<unknown>,
591
+ undefined | FormValidateOrFn<unknown>,
592
+ undefined | FormAsyncValidateOrFn<unknown>,
593
+ undefined | FormValidateOrFn<unknown>,
594
+ undefined | FormAsyncValidateOrFn<unknown>,
595
+ undefined | FormAsyncValidateOrFn<unknown>,
596
+ unknown extends TSubmitMeta ? never : TSubmitMeta,
597
+ FC,
598
+ FMC & FieldWrappers<FC, TFieldGroupData>
599
+ >
600
+ }
601
+ >
602
+ ) => ReactNode
603
+ }) {
604
+ return withTanStackFieldGroup<TFieldGroupData, TSubmitMeta, TRenderProps>({
605
+ ...options,
606
+ render: (props) =>
607
+ render({
608
+ ...props,
609
+ group: withNamedFields(props.group),
610
+ } as Parameters<typeof render>[0]),
611
+ })
429
612
  }
430
613
 
431
614
  // An unset generic defaults to its constraint, which is what TanStack's own
@@ -531,7 +714,17 @@ export function createZenoForm<
531
714
 
532
715
  const wrappedOnSubmit = userOnSubmit
533
716
  ? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
534
- const { submissionAttempts } = props.formApi.state
717
+ const { formApi } = props
718
+ const { submissionAttempts } = formApi.state
719
+ // A reset (a dialog closed meanwhile) or a newer submit while this
720
+ // one runs makes its failure stale, even once a later submit brings
721
+ // the count back. An edit doesn't: the save still didn't happen.
722
+ let superseded = false
723
+ const watch = formApi.store.subscribe(() => {
724
+ if (formApi.state.submissionAttempts !== submissionAttempts) {
725
+ superseded = true
726
+ }
727
+ })
535
728
  try {
536
729
  await (
537
730
  userOnSubmit as unknown as (
@@ -540,20 +733,15 @@ export function createZenoForm<
540
733
  )(props)
541
734
  } catch (error) {
542
735
  if (error instanceof ValidationError) {
543
- applyValidationError(props.formApi, error)
736
+ applyValidationError(formApi, error)
544
737
  return
545
738
  }
546
- // Kept for `FormError`, unless an edit, a reset (a dialog closed
547
- // meanwhile) or a newer submit came first: then it is about
548
- // values no longer on screen.
549
- const { state } = props.formApi
550
- if (
551
- state.values === props.value &&
552
- state.submissionAttempts === submissionAttempts
553
- ) {
554
- setSubmitError(props.formApi, error)
739
+ if (!superseded) {
740
+ setSubmitError(formApi, error)
555
741
  }
556
742
  throw error
743
+ } finally {
744
+ watch.unsubscribe()
557
745
  }
558
746
  }) as typeof userOnSubmit)
559
747
  : undefined
@@ -579,15 +767,21 @@ export function createZenoForm<
579
767
  [schema]
580
768
  )
581
769
 
770
+ // The caller's values keep only the keys the schema describes, so a
771
+ // record as read (`id`, `createdAt`, …) doesn't fail a `.strict()` schema
772
+ // on submit. The schema's own defaults then fill the keys left out.
582
773
  const mergedDefaultValues = useMemo(
583
774
  () =>
584
775
  schemaDefaults
585
776
  ? (deepMergeDefaults(
586
777
  schemaDefaults,
587
- userDefaultValues as Record<string, unknown> | undefined
778
+ pickSchemaShape(
779
+ schema as Parameters<typeof pickSchemaShape>[0],
780
+ userDefaultValues as Record<string, unknown> | undefined
781
+ )
588
782
  ) as typeof userDefaultValues)
589
783
  : userDefaultValues,
590
- [schemaDefaults, userDefaultValues]
784
+ [schema, schemaDefaults, userDefaultValues]
591
785
  )
592
786
 
593
787
  // Keep `formApi.reset(values)` from being undone by the next render; see
@@ -632,10 +826,9 @@ export function createZenoForm<
632
826
  useDisabledOption(form, disabled)
633
827
  useUnsavedChangesWarning(form, unsavedChangesWarning)
634
828
 
635
- const fields = useAppFields(form)
636
829
  return useMemo(
637
- () => Object.assign(form, fields, formComponents),
638
- [form, fields]
830
+ () => Object.assign(form, getAppFields(form), formComponents),
831
+ [form]
639
832
  )
640
833
  }
641
834
 
@@ -15,7 +15,7 @@ afterEach(() => {
15
15
  vi.unstubAllGlobals()
16
16
  })
17
17
 
18
- const { useForm } = createZenoForm({
18
+ const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
19
19
  defaultProps: {
20
20
  FormError: { submitErrorMessage: "Not saved. Try again later." },
21
21
  MoneyField: { currency: "CHF", currencyDisplay: "code", placeholder: "0" },
@@ -162,4 +162,28 @@ describe("createZenoForm({ defaultProps })", () => {
162
162
  render(<AppFieldHarness />)
163
163
  expect(addonOf("Amount")).toBe("CHF")
164
164
  })
165
+
166
+ test("a section's and a group's fields get their defaults too", () => {
167
+ const AmountSection = withForm({
168
+ defaultValues: VALUES,
169
+ render: ({ form }) => <form.MoneyField label="Amount" name="amount" />,
170
+ })
171
+ const FeeGroup = withFieldGroup({
172
+ defaultValues: { fee: VALUES.fee },
173
+ render: ({ group }) => <group.MoneyField label="Fee" name="fee" />,
174
+ })
175
+ function SectionHarness() {
176
+ // `useAppForm`'s form has no named fields until a section renders it.
177
+ const form = useAppForm({ defaultValues: VALUES, onSubmit: vi.fn() })
178
+ return (
179
+ <FormProvider form={form}>
180
+ <AmountSection form={form} />
181
+ <FeeGroup fields={{ fee: "fee" }} form={form} />
182
+ </FormProvider>
183
+ )
184
+ }
185
+ render(<SectionHarness />)
186
+ expect(addonOf("Amount")).toBe("CHF")
187
+ expect(addonOf("Fee")).toBe("CHF")
188
+ })
165
189
  })
@@ -178,6 +178,40 @@ describe("FormDialog", () => {
178
178
  expect(name.value).toBe("Ada")
179
179
  })
180
180
 
181
+ test("an edit opened on a record with keys the schema lacks saves to a .strict() schema", async () => {
182
+ const user = userEvent.setup()
183
+ const strictSchema = schema.strict()
184
+ const record = { ...ROW, createdAt: "2026-01-01", id: 7 }
185
+ const onSubmit = vi.fn()
186
+ function StrictHarness() {
187
+ const dialog = useFormDialog<Values>({ defaultValues: BLANK })
188
+ const form = useForm({
189
+ defaultValues: dialog.defaultValues,
190
+ onSubmit: ({ value }) => onSubmit(value),
191
+ schema: strictSchema,
192
+ })
193
+ const { InputField } = form
194
+ return (
195
+ <>
196
+ <button
197
+ onClick={() => dialog.open({ defaultValues: record })}
198
+ type="button"
199
+ >
200
+ Edit
201
+ </button>
202
+ <FormDialog dialog={dialog} form={form} title="Person">
203
+ <InputField label="Name" name="name" />
204
+ </FormDialog>
205
+ </>
206
+ )
207
+ }
208
+ render(<StrictHarness />)
209
+ await user.click(screen.getByRole("button", { name: "Edit" }))
210
+ await screen.findByLabelText(NAME)
211
+ await user.click(screen.getByRole("button", { name: "Save" }))
212
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(ROW))
213
+ })
214
+
181
215
  test("closes without asking when nothing changed", async () => {
182
216
  const user = userEvent.setup()
183
217
  render(<Harness />)
@@ -136,6 +136,53 @@ describe("<FormError> with <Form>", () => {
136
136
  expect(screen.queryByRole("alert")).toBeNull()
137
137
  })
138
138
 
139
+ test("a submit that throws after an edit made while it ran still shows", async () => {
140
+ const user = userEvent.setup()
141
+ let rejectSubmit: (error: Error) => void = () => undefined
142
+ const onSubmit = vi.fn(
143
+ () =>
144
+ new Promise((_, reject) => {
145
+ rejectSubmit = reject
146
+ })
147
+ )
148
+ render(<Profile onSubmit={onSubmit} />)
149
+ await user.click(screen.getByRole("button", SUBMIT))
150
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
151
+
152
+ await user.type(screen.getByLabelText(NAME), "!")
153
+ rejectSubmit(new Error("offline"))
154
+ expect(await screen.findByRole("alert")).toBeTruthy()
155
+
156
+ // The next edit clears it, as after any thrown submit.
157
+ await user.type(screen.getByLabelText(NAME), "?")
158
+ expect(screen.queryByRole("alert")).toBeNull()
159
+ })
160
+
161
+ test("a reset while a submit runs drops its error, even after a new submit", async () => {
162
+ const user = userEvent.setup()
163
+ let rejectFirst: (error: Error) => void = () => undefined
164
+ const onSubmit = vi
165
+ .fn<(values: Values) => unknown>()
166
+ .mockImplementationOnce(
167
+ () =>
168
+ new Promise((_, reject) => {
169
+ rejectFirst = reject
170
+ })
171
+ )
172
+ .mockImplementationOnce(() => new Promise(() => undefined))
173
+ render(<Profile onSubmit={onSubmit} />)
174
+ await user.click(screen.getByRole("button", SUBMIT))
175
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
176
+
177
+ await user.click(screen.getByRole("button", { name: "Reset" }))
178
+ // Back to the first submit's attempt count, still running.
179
+ await user.click(screen.getByRole("button", SUBMIT))
180
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2))
181
+ rejectFirst(new Error("offline"))
182
+ await new Promise((resolve) => setTimeout(resolve, 0))
183
+ expect(screen.queryByRole("alert")).toBeNull()
184
+ })
185
+
139
186
  test("a reset drops a thrown submit's error, even when it restores the same values", async () => {
140
187
  const user = userEvent.setup()
141
188
  let resolveRetry: () => void = () => undefined
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, test } from "vitest"
2
2
  import { z } from "zod"
3
- import { deepMergeDefaults, extractZodDefaults } from "./schema-defaults"
3
+ import {
4
+ deepMergeDefaults,
5
+ extractZodDefaults,
6
+ pickSchemaShape,
7
+ } from "./schema-defaults"
4
8
 
5
9
  describe("extractZodDefaults — primitives", () => {
6
10
  test("z.string() → ''", () => {
@@ -192,3 +196,126 @@ describe("deepMergeDefaults", () => {
192
196
  expect(deepMergeDefaults({ x: { a: 1 } }, { x: null })).toEqual({ x: null })
193
197
  })
194
198
  })
199
+
200
+ describe("pickSchemaShape", () => {
201
+ test("keeps only the keys the schema describes, at every depth", () => {
202
+ const schema = z
203
+ .object({
204
+ address: z.object({ city: z.string() }).strict(),
205
+ name: z.string(),
206
+ })
207
+ .strict()
208
+ const record = {
209
+ address: { city: "Geneva", geocodedAt: "2026-01-01" },
210
+ createdAt: new Date(),
211
+ id: 7,
212
+ name: "Ada",
213
+ }
214
+ expect(pickSchemaShape(schema, record)).toEqual({
215
+ address: { city: "Geneva" },
216
+ name: "Ada",
217
+ })
218
+ // The record is copied, never trimmed in place.
219
+ expect(record).toHaveProperty("id", 7)
220
+ })
221
+
222
+ test("maps an array's element over every row", () => {
223
+ const schema = z.object({
224
+ lines: z.array(z.object({ amount: z.number(), label: z.string() })),
225
+ })
226
+ expect(
227
+ pickSchemaShape(schema, {
228
+ lines: [
229
+ { amount: 1, id: 10, label: "Design" },
230
+ { amount: 2, id: 11, label: "Build" },
231
+ ],
232
+ })
233
+ ).toEqual({
234
+ lines: [
235
+ { amount: 1, label: "Design" },
236
+ { amount: 2, label: "Build" },
237
+ ],
238
+ })
239
+ })
240
+
241
+ test("unwraps optional, nullable and default, keeping null and absent keys", () => {
242
+ const address = z.object({ city: z.string() })
243
+ const schema = z.object({
244
+ billing: address.nullable(),
245
+ shipping: address.optional(),
246
+ tags: z.array(z.object({ label: z.string() })).default([]),
247
+ work: address.nullable(),
248
+ })
249
+ const picked = pickSchemaShape(schema, {
250
+ billing: null,
251
+ tags: [{ id: 1, label: "vip" }],
252
+ work: { city: "Bern", id: 3 },
253
+ })
254
+ expect(picked).toEqual({
255
+ billing: null,
256
+ tags: [{ label: "vip" }],
257
+ work: { city: "Bern" },
258
+ })
259
+ expect(picked).not.toHaveProperty("shipping")
260
+ })
261
+
262
+ test("reads a pipe or a transform through its input", () => {
263
+ const schema = z
264
+ .object({
265
+ amount: z.string().transform(Number),
266
+ period: z
267
+ .object({ from: z.string(), to: z.string() })
268
+ .transform(({ from, to }) => `${from}/${to}`),
269
+ })
270
+ .transform((invoice) => ({ ...invoice, total: invoice.amount }))
271
+ expect(
272
+ pickSchemaShape(schema, {
273
+ amount: "12",
274
+ id: 4,
275
+ period: { from: "2026-01", id: 9, to: "2026-02" },
276
+ })
277
+ ).toEqual({ amount: "12", period: { from: "2026-01", to: "2026-02" } })
278
+ })
279
+
280
+ test("keeps as is what the schema doesn't describe for certain", () => {
281
+ const schema = z.object({
282
+ contact: z.union([
283
+ z.object({ email: z.string() }),
284
+ z.object({ phone: z.string() }),
285
+ ]),
286
+ extra: z.object({ a: z.string() }).catchall(z.string()),
287
+ labels: z.record(z.string(), z.object({ text: z.string() })),
288
+ loose: z.looseObject({ a: z.string() }),
289
+ meta: z.unknown(),
290
+ profile: z.object({ nick: z.string() }),
291
+ })
292
+ const value = {
293
+ contact: { email: "ada@example.com", id: 1 },
294
+ extra: { a: "x", b: "y" },
295
+ labels: { en: { id: 2, text: "Hello" } },
296
+ loose: { a: "x", b: "y" },
297
+ meta: { anything: true },
298
+ profile: "not an object",
299
+ }
300
+ expect(pickSchemaShape(schema, value)).toEqual(value)
301
+ })
302
+
303
+ test("leaves a value alone without a Zod schema", () => {
304
+ const value = { id: 1, name: "Ada" }
305
+ expect(
306
+ pickSchemaShape({} as Parameters<typeof pickSchemaShape>[0], value)
307
+ ).toBe(value)
308
+ })
309
+
310
+ test("leaves a missing key absent, for the schema default to fill", () => {
311
+ const schema = z.object({
312
+ name: z.string(),
313
+ role: z.string().default("member"),
314
+ })
315
+ const picked = pickSchemaShape(schema, { id: 7, name: "Ada" })
316
+ expect(deepMergeDefaults(extractZodDefaults(schema), picked)).toEqual({
317
+ name: "Ada",
318
+ role: "member",
319
+ })
320
+ })
321
+ })