@zeno-lib/forms 0.0.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 (48) hide show
  1. package/package.json +47 -0
  2. package/src/addons/index.ts +2 -0
  3. package/src/addons/validation-spinner.tsx +25 -0
  4. package/src/fields/checkbox-field.tsx +80 -0
  5. package/src/fields/combobox-field.tsx +189 -0
  6. package/src/fields/date-picker-field.tsx +141 -0
  7. package/src/fields/email-field.tsx +21 -0
  8. package/src/fields/index.ts +16 -0
  9. package/src/fields/input-field.tsx +95 -0
  10. package/src/fields/number-field.tsx +33 -0
  11. package/src/fields/otp-field.tsx +111 -0
  12. package/src/fields/password-field.tsx +20 -0
  13. package/src/fields/radio-group-field.tsx +105 -0
  14. package/src/fields/reset-button.tsx +29 -0
  15. package/src/fields/select-field.tsx +113 -0
  16. package/src/fields/slider-field.tsx +103 -0
  17. package/src/fields/submit-button.tsx +31 -0
  18. package/src/fields/switch-field.tsx +80 -0
  19. package/src/fields/textarea-field.tsx +91 -0
  20. package/src/form.tsx +105 -0
  21. package/src/lib/apply-validation-error.test.ts +97 -0
  22. package/src/lib/apply-validation-error.ts +31 -0
  23. package/src/lib/aria.ts +7 -0
  24. package/src/lib/contexts.ts +9 -0
  25. package/src/lib/required-indicator.tsx +22 -0
  26. package/src/lib/schema-defaults.test.ts +195 -0
  27. package/src/lib/schema-defaults.ts +127 -0
  28. package/src/lib/schema-required.test.ts +132 -0
  29. package/src/lib/schema-required.ts +64 -0
  30. package/src/lib/use-is-invalid.ts +109 -0
  31. package/src/lib/use-unsaved-changes-warning.ts +57 -0
  32. package/src/lib/validation-error.test.ts +41 -0
  33. package/src/lib/validation-error.ts +30 -0
  34. package/src/lib/validation-logic.test.ts +261 -0
  35. package/src/lib/validation-logic.ts +192 -0
  36. package/src/lib/validation-modes.test-d.ts +9 -0
  37. package/src/lib/validation-modes.test.ts +72 -0
  38. package/src/lib/validation-modes.ts +73 -0
  39. package/src/tanstack.ts +2 -0
  40. package/src/use-app-fields.test-d.ts +112 -0
  41. package/src/use-app-fields.test.tsx +163 -0
  42. package/src/use-app-fields.tsx +272 -0
  43. package/src/use-form.cascade.test.tsx +83 -0
  44. package/src/use-form.test-d.ts +153 -0
  45. package/src/use-form.test.tsx +348 -0
  46. package/src/use-form.tsx +450 -0
  47. package/src/use-form.unsaved-warning.test.tsx +192 -0
  48. package/src/use-form.validation-modes.test.tsx +149 -0
package/src/form.tsx ADDED
@@ -0,0 +1,105 @@
1
+ /* biome-ignore-all lint/performance/noBarrelFile: so consumers depend only on `@zeno-lib/forms`. */
2
+ "use client"
3
+
4
+ import { type AnyFormApi, createFormHook } from "@tanstack/react-form"
5
+ import type { ComponentProps, FormEvent, ReactNode } from "react"
6
+
7
+ import { CheckboxField } from "./fields/checkbox-field"
8
+ import { ComboboxField } from "./fields/combobox-field"
9
+ import { DatePickerField } from "./fields/date-picker-field"
10
+ import { EmailField } from "./fields/email-field"
11
+ import { InputField } from "./fields/input-field"
12
+ import { NumberField } from "./fields/number-field"
13
+ import { OtpField } from "./fields/otp-field"
14
+ import { PasswordField } from "./fields/password-field"
15
+ import { RadioGroupField } from "./fields/radio-group-field"
16
+ import { ResetButton } from "./fields/reset-button"
17
+ import { SelectField } from "./fields/select-field"
18
+ import { SliderField } from "./fields/slider-field"
19
+ import { SubmitButton } from "./fields/submit-button"
20
+ import { SwitchField } from "./fields/switch-field"
21
+ import { TextAreaField } from "./fields/textarea-field"
22
+ import {
23
+ fieldContext,
24
+ formContext,
25
+ FormProvider as RawFormProvider,
26
+ useFormContext,
27
+ } from "./lib/contexts"
28
+
29
+ export * from "./addons"
30
+ export { RadioGroupFieldItem } from "./fields/radio-group-field"
31
+ export { applyValidationError } from "./lib/apply-validation-error"
32
+ export { useFieldContext, useFormContext } from "./lib/contexts"
33
+ export {
34
+ RequiredIndicator,
35
+ type RequiredIndicatorProps,
36
+ } from "./lib/required-indicator"
37
+ export {
38
+ useHideFieldErrors,
39
+ useIsFieldRequired,
40
+ useIsInvalid,
41
+ } from "./lib/use-is-invalid"
42
+ export { type FieldMessage, ValidationError } from "./lib/validation-error"
43
+ export { blurThenChangeLogic } from "./lib/validation-logic"
44
+ export type { ValidationMode } from "./lib/validation-modes"
45
+ export { useAppFields } from "./use-app-fields"
46
+ export { useForm } from "./use-form"
47
+
48
+ const { useAppForm, withFieldGroup, withForm } = createFormHook({
49
+ fieldComponents: {
50
+ CheckboxField,
51
+ ComboboxField,
52
+ DatePickerField,
53
+ EmailField,
54
+ InputField,
55
+ NumberField,
56
+ OtpField,
57
+ PasswordField,
58
+ RadioGroupField,
59
+ SelectField,
60
+ SliderField,
61
+ SwitchField,
62
+ TextAreaField,
63
+ },
64
+ fieldContext,
65
+ formComponents: {
66
+ ResetButton,
67
+ SubmitButton,
68
+ },
69
+ formContext,
70
+ })
71
+
72
+ export { useAppForm, withFieldGroup, withForm }
73
+
74
+ type FormProviderProps = {
75
+ children: ReactNode
76
+ form: { handleSubmit: () => unknown }
77
+ }
78
+
79
+ function FormProvider({ children, form }: FormProviderProps) {
80
+ return (
81
+ <RawFormProvider value={form as AnyFormApi}>{children}</RawFormProvider>
82
+ )
83
+ }
84
+
85
+ type FormProps = Omit<ComponentProps<"form">, "onSubmit">
86
+
87
+ function Form({ children, className, ...props }: FormProps) {
88
+ const form = useFormContext()
89
+ return (
90
+ <form
91
+ className={className}
92
+ noValidate
93
+ onSubmit={(event: FormEvent<HTMLFormElement>) => {
94
+ event.preventDefault()
95
+ event.stopPropagation()
96
+ Promise.resolve(form.handleSubmit()).catch(() => undefined)
97
+ }}
98
+ {...props}
99
+ >
100
+ {children}
101
+ </form>
102
+ )
103
+ }
104
+
105
+ export { Form, FormProvider }
@@ -0,0 +1,97 @@
1
+ import type { AnyFormApi } from "@tanstack/react-form"
2
+ import { describe, expect, test, vi } from "@zeno-lib/vitest"
3
+
4
+ import { applyValidationError } from "./apply-validation-error"
5
+ import { ValidationError } from "./validation-error"
6
+
7
+ type FieldMetaUpdater = (prev: {
8
+ errorMap?: Record<string, unknown>
9
+ errors?: unknown[]
10
+ }) => unknown
11
+
12
+ function makeFormStub() {
13
+ const setFieldMeta = vi.fn(
14
+ (_name: string, _updater: FieldMetaUpdater) => undefined
15
+ )
16
+ const setErrorMap = vi.fn((_map: unknown) => undefined)
17
+ return {
18
+ api: { setErrorMap, setFieldMeta } as unknown as AnyFormApi,
19
+ setErrorMap,
20
+ setFieldMeta,
21
+ }
22
+ }
23
+
24
+ describe("applyValidationError", () => {
25
+ test("writes single string message to errorMap.onChange and errors array", () => {
26
+ const { api, setFieldMeta } = makeFormStub()
27
+ applyValidationError(api, new ValidationError({ email: "Taken" }))
28
+ expect(setFieldMeta).toHaveBeenCalledTimes(1)
29
+ const [name, updater] = setFieldMeta.mock.calls[0] as [
30
+ string,
31
+ FieldMetaUpdater,
32
+ ]
33
+ expect(name).toBe("email")
34
+ const next = updater({}) as {
35
+ errors: { message: string }[]
36
+ errorMap: { onChange: { message: string } }
37
+ isValid: boolean
38
+ }
39
+ expect(next.errors).toEqual([{ message: "Taken" }])
40
+ expect(next.errorMap.onChange).toEqual({ message: "Taken" })
41
+ expect(next.isValid).toBe(false)
42
+ })
43
+
44
+ test("array of messages forwards all entries; errorMap.onChange gets first", () => {
45
+ const { api, setFieldMeta } = makeFormStub()
46
+ applyValidationError(
47
+ api,
48
+ new ValidationError({ password: ["short", "no digit"] })
49
+ )
50
+ const updater = setFieldMeta.mock.calls[0]?.[1] as FieldMetaUpdater
51
+ const next = updater({}) as {
52
+ errors: { message: string }[]
53
+ errorMap: { onChange: { message: string } }
54
+ }
55
+ expect(next.errors).toEqual([{ message: "short" }, { message: "no digit" }])
56
+ expect(next.errorMap.onChange).toEqual({ message: "short" })
57
+ })
58
+
59
+ test("preserves existing errorMap keys via spread", () => {
60
+ const { api, setFieldMeta } = makeFormStub()
61
+ applyValidationError(api, new ValidationError({ email: "Taken" }))
62
+ const updater = setFieldMeta.mock.calls[0]?.[1] as FieldMetaUpdater
63
+ const next = updater({ errorMap: { onBlur: { message: "Old" } } }) as {
64
+ errorMap: { onBlur: { message: string }; onChange: { message: string } }
65
+ }
66
+ expect(next.errorMap.onBlur).toEqual({ message: "Old" })
67
+ expect(next.errorMap.onChange).toEqual({ message: "Taken" })
68
+ })
69
+
70
+ test("formError populates form-level error map via setErrorMap", () => {
71
+ const { api, setErrorMap } = makeFormStub()
72
+ applyValidationError(
73
+ api,
74
+ new ValidationError({ email: "Taken" }, { formError: "Server down" })
75
+ )
76
+ expect(setErrorMap).toHaveBeenCalledWith({
77
+ onSubmit: { fields: {}, form: "Server down" },
78
+ })
79
+ })
80
+
81
+ test("no formError → setErrorMap not called", () => {
82
+ const { api, setErrorMap } = makeFormStub()
83
+ applyValidationError(api, new ValidationError({ email: "Taken" }))
84
+ expect(setErrorMap).not.toHaveBeenCalled()
85
+ })
86
+
87
+ test("multiple fields → setFieldMeta called per field", () => {
88
+ const { api, setFieldMeta } = makeFormStub()
89
+ applyValidationError(
90
+ api,
91
+ new ValidationError({ email: "Taken", name: "Required" })
92
+ )
93
+ expect(setFieldMeta).toHaveBeenCalledTimes(2)
94
+ const names = setFieldMeta.mock.calls.map((c) => c[0])
95
+ expect(names).toEqual(expect.arrayContaining(["email", "name"]))
96
+ })
97
+ })
@@ -0,0 +1,31 @@
1
+ import type { AnyFormApi } from "@tanstack/react-form"
2
+
3
+ import type { ValidationError } from "./validation-error"
4
+
5
+ // Write per-field messages from a `ValidationError` onto the form. We target
6
+ // `errorMap.onChange` so the form-level schema overwrites the message as
7
+ // soon as the user edits the field; the error clears automatically the
8
+ // moment the value passes validation. Form-level error message is written
9
+ // to the form's overall error map under the `onSubmit` cause.
10
+ function applyValidationError(
11
+ formApi: AnyFormApi,
12
+ error: ValidationError
13
+ ): void {
14
+ for (const [name, message] of Object.entries(error.fields)) {
15
+ const messages = Array.isArray(message) ? message : [message]
16
+ const errors = messages.map((m) => ({ message: m }))
17
+ formApi.setFieldMeta(name, (prev) => ({
18
+ ...prev,
19
+ errorMap: { ...prev.errorMap, onChange: errors[0] },
20
+ errors,
21
+ isValid: false,
22
+ }))
23
+ }
24
+ if (error.formError) {
25
+ formApi.setErrorMap({
26
+ onSubmit: { fields: {}, form: error.formError },
27
+ })
28
+ }
29
+ }
30
+
31
+ export { applyValidationError }
@@ -0,0 +1,7 @@
1
+ // Builds an aria-describedby string from [condition, id] pairs; only ids with a truthy condition are included.
2
+ export function describedBy(
3
+ ...entries: [unknown, string][]
4
+ ): string | undefined {
5
+ const ids = entries.filter(([show]) => Boolean(show)).map(([, id]) => id)
6
+ return ids.length > 0 ? ids.join(" ") : undefined
7
+ }
@@ -0,0 +1,9 @@
1
+ "use client"
2
+
3
+ import { createFormHookContexts } from "@tanstack/react-form"
4
+
5
+ const { fieldContext, formContext, useFieldContext, useFormContext } =
6
+ createFormHookContexts()
7
+
8
+ export { fieldContext, formContext, useFieldContext, useFormContext }
9
+ export const FormProvider = formContext.Provider
@@ -0,0 +1,22 @@
1
+ "use client"
2
+
3
+ import { cn } from "@zeno-lib/ui/lib/utils"
4
+ import type { ComponentProps } from "react"
5
+
6
+ type RequiredIndicatorProps = ComponentProps<"span">
7
+
8
+ function RequiredIndicator({ className, ...props }: RequiredIndicatorProps) {
9
+ return (
10
+ <span
11
+ aria-hidden="true"
12
+ className={cn("text-destructive", className)}
13
+ data-slot="required-indicator"
14
+ {...props}
15
+ >
16
+ *
17
+ </span>
18
+ )
19
+ }
20
+
21
+ export type { RequiredIndicatorProps }
22
+ export { RequiredIndicator }
@@ -0,0 +1,195 @@
1
+ import { describe, expect, test } from "@zeno-lib/vitest"
2
+ import { z } from "zod"
3
+
4
+ import { deepMergeDefaults, extractZodDefaults } from "./schema-defaults"
5
+
6
+ describe("extractZodDefaults — primitives", () => {
7
+ test("z.string() → ''", () => {
8
+ expect(extractZodDefaults(z.object({ x: z.string() }))).toEqual({ x: "" })
9
+ })
10
+
11
+ test("z.email() / z.url() / z.uuid() → ''", () => {
12
+ const schema = z.object({
13
+ email: z.email(),
14
+ id: z.uuid(),
15
+ url: z.url(),
16
+ })
17
+ expect(extractZodDefaults(schema)).toEqual({ email: "", id: "", url: "" })
18
+ })
19
+
20
+ test("z.array(...) → []", () => {
21
+ expect(extractZodDefaults(z.object({ tags: z.array(z.string()) }))).toEqual(
22
+ { tags: [] }
23
+ )
24
+ })
25
+
26
+ test("z.number() / z.boolean() / z.date() are skipped", () => {
27
+ const schema = z.object({
28
+ active: z.boolean(),
29
+ age: z.number(),
30
+ created: z.date(),
31
+ })
32
+ expect(extractZodDefaults(schema)).toEqual({})
33
+ })
34
+
35
+ test("z.enum / z.literal are skipped", () => {
36
+ const schema = z.object({
37
+ kind: z.literal("a"),
38
+ role: z.enum(["admin", "user"]),
39
+ })
40
+ expect(extractZodDefaults(schema)).toEqual({})
41
+ })
42
+ })
43
+
44
+ describe("extractZodDefaults — wrappers", () => {
45
+ test(".default(value) flows through", () => {
46
+ const schema = z.object({
47
+ n: z.number().default(7),
48
+ role: z.string().default("member"),
49
+ })
50
+ expect(extractZodDefaults(schema)).toEqual({ n: 7, role: "member" })
51
+ })
52
+
53
+ test(".prefault(value) flows through", () => {
54
+ const schema = z.object({
55
+ role: z.prefault(z.string(), "guest"),
56
+ })
57
+ expect(extractZodDefaults(schema)).toEqual({ role: "guest" })
58
+ })
59
+
60
+ test(".optional() unwraps to inner string → ''", () => {
61
+ const schema = z.object({ x: z.string().optional() })
62
+ expect(extractZodDefaults(schema)).toEqual({ x: "" })
63
+ })
64
+
65
+ test(".nullable() unwraps to inner string → ''", () => {
66
+ const schema = z.object({ x: z.string().nullable() })
67
+ expect(extractZodDefaults(schema)).toEqual({ x: "" })
68
+ })
69
+
70
+ test(".readonly() unwraps to inner string → ''", () => {
71
+ const schema = z.object({ x: z.string().readonly() })
72
+ expect(extractZodDefaults(schema)).toEqual({ x: "" })
73
+ })
74
+
75
+ test("z.pipe uses the in side", () => {
76
+ const schema = z.object({
77
+ n: z.string().pipe(z.coerce.number()),
78
+ })
79
+ // The `in` side is z.string() → ''
80
+ expect(extractZodDefaults(schema)).toEqual({ n: "" })
81
+ })
82
+
83
+ test(".optional() over a number is skipped", () => {
84
+ const schema = z.object({ x: z.number().optional() })
85
+ expect(extractZodDefaults(schema)).toEqual({})
86
+ })
87
+ })
88
+
89
+ describe("extractZodDefaults — nested objects", () => {
90
+ test("nested z.object recurses", () => {
91
+ const schema = z.object({
92
+ profile: z.object({
93
+ email: z.email(),
94
+ nick: z.string(),
95
+ }),
96
+ })
97
+ expect(extractZodDefaults(schema)).toEqual({
98
+ profile: { email: "", nick: "" },
99
+ })
100
+ })
101
+
102
+ test("empty nested object is skipped", () => {
103
+ const schema = z.object({
104
+ meta: z.object({
105
+ active: z.boolean(),
106
+ age: z.number(),
107
+ }),
108
+ })
109
+ expect(extractZodDefaults(schema)).toEqual({})
110
+ })
111
+
112
+ test("partial nested object produced when at least one leaf survives", () => {
113
+ const schema = z.object({
114
+ profile: z.object({
115
+ age: z.number(),
116
+ name: z.string(),
117
+ }),
118
+ })
119
+ expect(extractZodDefaults(schema)).toEqual({ profile: { name: "" } })
120
+ })
121
+ })
122
+
123
+ describe("extractZodDefaults — degrades safely", () => {
124
+ test("non-object root returns {}", () => {
125
+ expect(extractZodDefaults(z.string() as never)).toEqual({})
126
+ })
127
+
128
+ test("non-Zod schema returns {}", () => {
129
+ expect(
130
+ extractZodDefaults({} as Parameters<typeof extractZodDefaults>[0])
131
+ ).toEqual({})
132
+ })
133
+
134
+ test("MAX_WRAPPER_DEPTH=16 does not loop on adversarial chains", () => {
135
+ // Build a cyclic wrapper chain (innerType points back at itself).
136
+ const cyclic: { _zod: { def: unknown } } = {
137
+ _zod: { def: { innerType: null as unknown, type: "optional" } },
138
+ }
139
+ ;(cyclic._zod.def as { innerType: unknown }).innerType = cyclic
140
+ const root = {
141
+ _zod: { def: { shape: { x: cyclic }, type: "object" } },
142
+ } as unknown as Parameters<typeof extractZodDefaults>[0]
143
+ // Should bail out at MAX_WRAPPER_DEPTH and skip the field — not hang.
144
+ expect(extractZodDefaults(root)).toEqual({})
145
+ })
146
+ })
147
+
148
+ describe("deepMergeDefaults", () => {
149
+ test("undefined user defaults returns schema defaults reference", () => {
150
+ const schema = { a: 1, nested: { b: 2 } }
151
+ expect(deepMergeDefaults(schema, undefined)).toBe(schema)
152
+ })
153
+
154
+ test("scalar user value wins over schema scalar", () => {
155
+ expect(deepMergeDefaults({ a: 1, b: "x" }, { a: 5 })).toEqual({
156
+ a: 5,
157
+ b: "x",
158
+ })
159
+ })
160
+
161
+ test("plain object on both sides recurses", () => {
162
+ expect(
163
+ deepMergeDefaults({ nested: { a: 1, b: 2 } }, { nested: { b: 99 } })
164
+ ).toEqual({ nested: { a: 1, b: 99 } })
165
+ })
166
+
167
+ test("array is treated atomically (user replaces schema)", () => {
168
+ expect(deepMergeDefaults({ tags: ["a", "b"] }, { tags: ["c"] })).toEqual({
169
+ tags: ["c"],
170
+ })
171
+ })
172
+
173
+ test("Date is treated atomically", () => {
174
+ const schemaDate = new Date("2020-01-01")
175
+ const userDate = new Date("2030-01-01")
176
+ expect(deepMergeDefaults({ when: schemaDate }, { when: userDate })).toEqual(
177
+ { when: userDate }
178
+ )
179
+ })
180
+
181
+ test("user explicit undefined preserved (intentional reset)", () => {
182
+ expect(deepMergeDefaults({ a: 1, b: 2 }, { a: undefined })).toEqual({
183
+ a: undefined,
184
+ b: 2,
185
+ })
186
+ })
187
+
188
+ test("key only present in user value flows through", () => {
189
+ expect(deepMergeDefaults({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 })
190
+ })
191
+
192
+ test("schema object + user scalar → user wins (no merge attempt)", () => {
193
+ expect(deepMergeDefaults({ x: { a: 1 } }, { x: null })).toEqual({ x: null })
194
+ })
195
+ })
@@ -0,0 +1,127 @@
1
+ // Extract default values from a Zod schema for `useForm`.
2
+ //
3
+ // Tier A+ semantics:
4
+ // - `.default(...)` / `.prefault(...)` values flow through.
5
+ // - Strings (and string-format subtypes — email, url, uuid, regex, …) without
6
+ // a default fall back to `""` so React inputs render controlled.
7
+ // - Arrays without a default fall back to `[]`.
8
+ // - Nested `z.object(...)` recurses; emits a sub-record only if at least one
9
+ // leaf produced a value.
10
+ // - Numbers, booleans, dates, enums, unions, intersections, files, literals,
11
+ // etc. are skipped — those carry domain meaning and the user supplies them
12
+ // explicitly via `defaultValues`.
13
+ //
14
+ // Standard Schema doesn't expose introspection; this is Zod-only and reads
15
+ // `_zod.def` (Zod v4). For non-Zod schemas (or unrecognised shapes) every entry
16
+ // silently degrades to "skip", and the user's `defaultValues` flows through
17
+ // untouched. Async schemas, lazy schemas, and any throw inside the walk also
18
+ // degrade to "skip" — never bring the form down.
19
+
20
+ type SchemaNode = { readonly _zod?: { readonly def?: SchemaDef } }
21
+
22
+ type SchemaDef = {
23
+ readonly type?: string
24
+ readonly innerType?: SchemaNode
25
+ readonly shape?: Record<string, SchemaNode>
26
+ readonly in?: SchemaNode
27
+ readonly defaultValue?: unknown
28
+ }
29
+
30
+ const MAX_WRAPPER_DEPTH = 16
31
+
32
+ type ExtractResult = { ok: true; value: unknown } | { ok: false }
33
+
34
+ function getDef(node: SchemaNode | undefined): SchemaDef | undefined {
35
+ return node?._zod?.def
36
+ }
37
+
38
+ function extractDefaultForField(node: SchemaNode): ExtractResult {
39
+ let current: SchemaNode | undefined = node
40
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth++) {
41
+ const def: SchemaDef | undefined = getDef(current)
42
+ if (!def?.type) {
43
+ return { ok: false }
44
+ }
45
+ switch (def.type) {
46
+ case "default":
47
+ case "prefault":
48
+ return { ok: true, value: def.defaultValue }
49
+ case "optional":
50
+ case "nullable":
51
+ case "nonoptional":
52
+ case "readonly":
53
+ current = def.innerType
54
+ continue
55
+ case "pipe":
56
+ current = def.in
57
+ continue
58
+ case "string":
59
+ return { ok: true, value: "" }
60
+ case "array":
61
+ return { ok: true, value: [] }
62
+ case "object": {
63
+ const nested = current ? extractZodDefaults(current) : {}
64
+ return Object.keys(nested).length > 0
65
+ ? { ok: true, value: nested }
66
+ : { ok: false }
67
+ }
68
+ default:
69
+ return { ok: false }
70
+ }
71
+ }
72
+ return { ok: false }
73
+ }
74
+
75
+ function extractZodDefaults(schema: SchemaNode): Record<string, unknown> {
76
+ try {
77
+ const def: SchemaDef | undefined = getDef(schema)
78
+ if (def?.type !== "object" || !def.shape) {
79
+ return {}
80
+ }
81
+ const result: Record<string, unknown> = {}
82
+ for (const [key, field] of Object.entries(def.shape)) {
83
+ const extracted = extractDefaultForField(field)
84
+ if (extracted.ok) {
85
+ result[key] = extracted.value
86
+ }
87
+ }
88
+ return result
89
+ } catch {
90
+ return {}
91
+ }
92
+ }
93
+
94
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
95
+ return (
96
+ typeof value === "object" &&
97
+ value !== null &&
98
+ Object.getPrototypeOf(value) === Object.prototype
99
+ )
100
+ }
101
+
102
+ // User-supplied values always win on conflict. Plain objects on both sides
103
+ // recurse so nested `z.object(...)` defaults aren't clobbered by partial user
104
+ // objects. Arrays, dates, class instances are treated atomically (user value
105
+ // replaces schema value wholesale). User-explicit `undefined` is preserved as
106
+ // an intentional reset.
107
+ function deepMergeDefaults(
108
+ schemaDefaults: Record<string, unknown>,
109
+ userDefaults: Record<string, unknown> | undefined
110
+ ): Record<string, unknown> {
111
+ if (!userDefaults) {
112
+ return schemaDefaults
113
+ }
114
+ const result: Record<string, unknown> = { ...schemaDefaults }
115
+ for (const key of Object.keys(userDefaults)) {
116
+ const userValue = userDefaults[key]
117
+ const schemaValue = schemaDefaults[key]
118
+ if (isPlainObject(schemaValue) && isPlainObject(userValue)) {
119
+ result[key] = deepMergeDefaults(schemaValue, userValue)
120
+ } else {
121
+ result[key] = userValue
122
+ }
123
+ }
124
+ return result
125
+ }
126
+
127
+ export { deepMergeDefaults, extractZodDefaults }