[{"data":1,"prerenderedAt":710},["ShallowReactive",2],{"content-\u002Fdocs\u002Freading-the-form\u002Ftype-safety":3},{"id":4,"title":5,"body":6,"description":690,"extension":691,"meta":692,"metaRows":693,"navigation":331,"path":705,"seo":706,"source":707,"stem":708,"__hash__":709},"docs\u002Fdocs\u002Freading-the-form\u002Ftype-safety.md","Type safety",{"type":7,"value":8,"toc":681},"minimark",[9,13,20,23,26,55,59,64,67,144,192,204,214,218,343,358,375,381,449,469,476,480,485,592,599,602,606,622,645,648,652,677],[10,11,5],"h1",{"id":12},"type-safety",[14,15,16],"blockquote",{},[17,18,19],"p",{},"Wide while the user is typing, tight the moment the schema validates. Two surfaces, one promise: what you see is what is actually there.",[21,22],"docs-meta-table",{},[17,24,25],{},"A form library has to tell the truth about what is in the form. Mid-typing, that is whatever the user has put there so far: an incomplete email, an undecided radio pick, a partially-filled subtree rehydrated from yesterday's session. Post-submit, it is the Zod schema's parsed output: every refinement honored, every literal narrowed, every transform applied.",[17,27,28,29,33,34,37,38,41,42,46,47,50,51,54],{},"Attaform's types model both states. ",[30,31,32],"code",{},"form.values",", ",[30,35,36],{},"defaultValues",", and ",[30,39,40],{},"form.setValue"," use the ",[43,44,45],"strong",{},"in-flight shape",", wide enough to hold whatever the form is actually carrying. ",[30,48,49],{},"handleSubmit((values) => ...)"," hands you the ",[43,52,53],{},"validated shape",": narrow, exact, what the schema promised.",[56,57],"docs-demo",{"label":58,"slug":12},"In-flight vs. validated types",[60,61,63],"h2",{"id":62},"the-gateway-example-email","The gateway example: email",[17,65,66],{},"Take the simplest schema:",[68,69,74],"pre",{"className":70,"code":71,"language":72,"meta":73,"style":73},"language-ts shiki shiki-themes github-light github-dark","const schema = z.object({\n  email: z.email('Enter a valid email'),\n})\nconst form = useForm({ schema })\n","ts","",[30,75,76,103,122,128],{"__ignoreMap":73},[77,78,81,85,89,92,96,100],"span",{"class":79,"line":80},"line",1,[77,82,84],{"class":83},"szBVR","const",[77,86,88],{"class":87},"sj4cs"," schema",[77,90,91],{"class":83}," =",[77,93,95],{"class":94},"sVt8B"," z.",[77,97,99],{"class":98},"sScJk","object",[77,101,102],{"class":94},"({\n",[77,104,106,109,112,115,119],{"class":79,"line":105},2,[77,107,108],{"class":94},"  email: z.",[77,110,111],{"class":98},"email",[77,113,114],{"class":94},"(",[77,116,118],{"class":117},"sZZnC","'Enter a valid email'",[77,120,121],{"class":94},"),\n",[77,123,125],{"class":79,"line":124},3,[77,126,127],{"class":94},"})\n",[77,129,131,133,136,138,141],{"class":79,"line":130},4,[77,132,84],{"class":83},[77,134,135],{"class":87}," form",[77,137,91],{"class":83},[77,139,140],{"class":98}," useForm",[77,142,143],{"class":94},"({ schema })\n",[17,145,146,147,150,151,154,155,158,159,162,163,165,166,168,169,172,173,172,176,179,180,183,184,191],{},"What is the type of ",[30,148,149],{},"form.values.email","? ",[30,152,153],{},"string",". Not a branded ",[30,156,157],{},"Email",", not ",[30,160,161],{},"string & { __brand: 'email' }",". Plain ",[30,164,153],{},". The reason is that during form completion, ",[30,167,149],{}," legitimately holds ",[30,170,171],{},"\"a\"",", then ",[30,174,175],{},"\"andy\"",[30,177,178],{},"\"andy@\"",". None of those satisfy ",[30,181,182],{},"z.email()",", and the schema knows that. That is why ",[185,186,188],"a",{"href":187},"\u002Fdocs\u002Freading-the-form\u002Ferrors",[30,189,190],{},"form.errors.email"," populates while the user is mid-typing.",[17,193,194,195,197,198,200,201,203],{},"If ",[30,196,149],{}," were typed as a branded ",[30,199,157],{},", the type would lie about what is actually there. The runtime value would be ",[30,202,178],{},"; the static type would claim it is a valid email. The compiler cannot help you when it has been told a fiction.",[17,205,206,207,209,210,213],{},"So the type follows reality: while the user is typing, ",[30,208,111],{}," is whatever string they have typed. After the schema parses successfully inside ",[30,211,212],{},"handleSubmit",", it is whatever the schema promises.",[60,215,217],{"id":216},"discriminators-do-the-same-thing","Discriminators do the same thing",[68,219,221],{"className":70,"code":220,"language":72,"meta":73,"style":73},"const schema = z.object({\n  transport: z.discriminatedUnion('kind', [\n    z.object({ kind: z.literal('boat'), hullLengthM: z.number() }),\n    z.object({ kind: z.literal('truck'), payloadKg: z.number() }),\n  ]),\n})\nconst form = useForm({ schema })\n\nform.values.transport.kind \u002F\u002F string (not 'boat' | 'truck')\n",[30,222,223,237,253,280,302,308,313,326,333],{"__ignoreMap":73},[77,224,225,227,229,231,233,235],{"class":79,"line":80},[77,226,84],{"class":83},[77,228,88],{"class":87},[77,230,91],{"class":83},[77,232,95],{"class":94},[77,234,99],{"class":98},[77,236,102],{"class":94},[77,238,239,242,245,247,250],{"class":79,"line":105},[77,240,241],{"class":94},"  transport: z.",[77,243,244],{"class":98},"discriminatedUnion",[77,246,114],{"class":94},[77,248,249],{"class":117},"'kind'",[77,251,252],{"class":94},", [\n",[77,254,255,258,260,263,266,268,271,274,277],{"class":79,"line":124},[77,256,257],{"class":94},"    z.",[77,259,99],{"class":98},[77,261,262],{"class":94},"({ kind: z.",[77,264,265],{"class":98},"literal",[77,267,114],{"class":94},[77,269,270],{"class":117},"'boat'",[77,272,273],{"class":94},"), hullLengthM: z.",[77,275,276],{"class":98},"number",[77,278,279],{"class":94},"() }),\n",[77,281,282,284,286,288,290,292,295,298,300],{"class":79,"line":130},[77,283,257],{"class":94},[77,285,99],{"class":98},[77,287,262],{"class":94},[77,289,265],{"class":98},[77,291,114],{"class":94},[77,293,294],{"class":117},"'truck'",[77,296,297],{"class":94},"), payloadKg: z.",[77,299,276],{"class":98},[77,301,279],{"class":94},[77,303,305],{"class":79,"line":304},5,[77,306,307],{"class":94},"  ]),\n",[77,309,311],{"class":79,"line":310},6,[77,312,127],{"class":94},[77,314,316,318,320,322,324],{"class":79,"line":315},7,[77,317,84],{"class":83},[77,319,135],{"class":87},[77,321,91],{"class":83},[77,323,140],{"class":98},[77,325,143],{"class":94},[77,327,329],{"class":79,"line":328},8,[77,330,332],{"emptyLinePlaceholder":331},true,"\n",[77,334,336,339],{"class":79,"line":335},9,[77,337,338],{"class":94},"form.values.transport.kind ",[77,340,342],{"class":341},"sJ8bj","\u002F\u002F string (not 'boat' | 'truck')\n",[17,344,345,346,349,350,353,354,357],{},"Same reasoning, one layer up. The user may not have picked a variant yet. They may be mid-keystroke in an input wired to the discriminator. They may be rehydrating a draft that was saved with ",[30,347,348],{},"kind: ''",". Typing ",[30,351,352],{},"transport.kind"," as ",[30,355,356],{},"'boat' | 'truck'"," would lie about every one of those cases.",[17,359,360,361,363,364,367,368,370,371,374],{},"The literal narrowing engages where it earns its keep: inside ",[30,362,212],{},", after the schema confirms the discriminator is one of the variants. There, ",[30,365,366],{},"values.transport.kind"," is ",[30,369,356],{},", and TypeScript narrows ",[30,372,373],{},"values.transport"," to the matching variant.",[60,376,378,380],{"id":377},"defaultvalues-works-the-same-way",[30,379,36],{}," works the same way",[68,382,384],{"className":70,"code":383,"language":72,"meta":73,"style":73},"const form = useForm({\n  schema,\n  defaultValues: {\n    email: '', \u002F\u002F empty string is fine, even though z.email() will reject it\n    transport: { kind: 'boat', hullLengthM: 0 }, \u002F\u002F any starting state, even invalid\n  },\n})\n",[30,385,386,398,403,408,421,440,445],{"__ignoreMap":73},[77,387,388,390,392,394,396],{"class":79,"line":80},[77,389,84],{"class":83},[77,391,135],{"class":87},[77,393,91],{"class":83},[77,395,140],{"class":98},[77,397,102],{"class":94},[77,399,400],{"class":79,"line":105},[77,401,402],{"class":94},"  schema,\n",[77,404,405],{"class":79,"line":124},[77,406,407],{"class":94},"  defaultValues: {\n",[77,409,410,413,416,418],{"class":79,"line":130},[77,411,412],{"class":94},"    email: ",[77,414,415],{"class":117},"''",[77,417,33],{"class":94},[77,419,420],{"class":341},"\u002F\u002F empty string is fine, even though z.email() will reject it\n",[77,422,423,426,428,431,434,437],{"class":79,"line":304},[77,424,425],{"class":94},"    transport: { kind: ",[77,427,270],{"class":117},[77,429,430],{"class":94},", hullLengthM: ",[77,432,433],{"class":87},"0",[77,435,436],{"class":94}," }, ",[77,438,439],{"class":341},"\u002F\u002F any starting state, even invalid\n",[77,441,442],{"class":79,"line":310},[77,443,444],{"class":94},"  },\n",[77,446,447],{"class":79,"line":315},[77,448,127],{"class":94},[17,450,451,452,454,455,33,458,461,462,465,466,468],{},"Rehydration is the killer use case. Yesterday's user opened the form, picked a variant, typed half an email, then closed the tab. Today's ",[30,453,36],{}," (re-read from ",[30,456,457],{},"localStorage",[30,459,460],{},"sessionStorage",", or your server) needs to faithfully restore that half-filled state. If ",[30,463,464],{},"defaultValues.email"," required a value that already passed ",[30,467,182],{},", you could not rehydrate the \"still typing\" case at all. You would be forced to invent a valid email the user never typed, or wipe the field entirely and lose their work.",[17,470,471,472,475],{},"Wide in-flight types mean you can store, hydrate, autosave, and resume from any intermediate state. The schema is still in charge of what counts as valid; the types just acknowledge that the form's job is to ",[43,473,474],{},"get there",", not to always be there.",[60,477,479],{"id":478},"where-the-types-tighten","Where the types tighten",[17,481,482,484],{},[30,483,212],{}," is the boundary:",[68,486,488],{"className":70,"code":487,"language":72,"meta":73,"style":73},"const onSubmit = form.handleSubmit((values) => {\n  values.email \u002F\u002F string (validated, passed z.email())\n  values.transport.kind \u002F\u002F 'boat' | 'truck'\n\n  if (values.transport.kind === 'boat') {\n    values.transport.hullLengthM \u002F\u002F narrowed to number\n  } else {\n    values.transport.payloadKg \u002F\u002F narrowed to number\n  }\n})\n",[30,489,490,520,528,536,540,557,565,575,582,587],{"__ignoreMap":73},[77,491,492,494,497,499,502,504,507,511,514,517],{"class":79,"line":80},[77,493,84],{"class":83},[77,495,496],{"class":87}," onSubmit",[77,498,91],{"class":83},[77,500,501],{"class":94}," form.",[77,503,212],{"class":98},[77,505,506],{"class":94},"((",[77,508,510],{"class":509},"s4XuR","values",[77,512,513],{"class":94},") ",[77,515,516],{"class":83},"=>",[77,518,519],{"class":94}," {\n",[77,521,522,525],{"class":79,"line":105},[77,523,524],{"class":94},"  values.email ",[77,526,527],{"class":341},"\u002F\u002F string (validated, passed z.email())\n",[77,529,530,533],{"class":79,"line":124},[77,531,532],{"class":94},"  values.transport.kind ",[77,534,535],{"class":341},"\u002F\u002F 'boat' | 'truck'\n",[77,537,538],{"class":79,"line":130},[77,539,332],{"emptyLinePlaceholder":331},[77,541,542,545,548,551,554],{"class":79,"line":304},[77,543,544],{"class":83},"  if",[77,546,547],{"class":94}," (values.transport.kind ",[77,549,550],{"class":83},"===",[77,552,553],{"class":117}," 'boat'",[77,555,556],{"class":94},") {\n",[77,558,559,562],{"class":79,"line":310},[77,560,561],{"class":94},"    values.transport.hullLengthM ",[77,563,564],{"class":341},"\u002F\u002F narrowed to number\n",[77,566,567,570,573],{"class":79,"line":315},[77,568,569],{"class":94},"  } ",[77,571,572],{"class":83},"else",[77,574,519],{"class":94},[77,576,577,580],{"class":79,"line":328},[77,578,579],{"class":94},"    values.transport.payloadKg ",[77,581,564],{"class":341},[77,583,584],{"class":79,"line":335},[77,585,586],{"class":94},"  }\n",[77,588,590],{"class":79,"line":589},10,[77,591,127],{"class":94},[17,593,594,595,598],{},"The argument to your success callback is ",[30,596,597],{},"z.infer\u003Ctypeof schema>",", Zod's parsed output. Every literal is preserved, every refinement is honored, every transform is applied. If the schema did not parse cleanly, the success callback never fires; the error callback runs instead with the in-flight values for you to inspect.",[17,600,601],{},"That is the contract: while the form is being filled in, the types help you handle every shape it might be in. The moment validation succeeds, the types snap to what the schema actually guarantees.",[60,603,605],{"id":604},"why-not-narrow-earlier","Why not narrow earlier?",[17,607,608,609,611,612,614,615,618,619,621],{},"A library that narrowed ",[30,610,149],{}," to a branded ",[30,613,157],{}," (or ",[30,616,617],{},"form.values.transport.kind"," to ",[30,620,356],{},") before validation would have to do one of two things, both bad:",[623,624,625,635],"ul",{},[626,627,628,631,632,634],"li",{},[43,629,630],{},"Refuse to type partial state."," Empty strings, half-typed emails, undecided discriminators are all invalid against the schema. Either ",[30,633,32],{}," could not represent them (the form cannot work) or the types would lie (the compiler cannot help).",[626,636,637,640,641,644],{},[43,638,639],{},"Force consumers to cast."," Every read becomes ",[30,642,643],{},"as string",", every assignment becomes a fight with the type checker. The schema's value as a source of truth evaporates because you are routing around it on every line.",[17,646,647],{},"Wide in-flight types skip both failure modes. The schema still owns what counts as valid; the form just acknowledges that getting to valid is the user's job, not the type system's.",[60,649,651],{"id":650},"where-to-next","Where to next",[623,653,654,662,670],{},[626,655,656,661],{},[185,657,659],{"href":658},"\u002Fdocs\u002Freading-the-form\u002Fvalues",[30,660,510],{},": the in-flight read surface this page is built on.",[626,663,664,669],{},[185,665,667],{"href":666},"\u002Fdocs\u002Fsubmitting\u002Fhandle-submit",[30,668,212],{},": the boundary where types tighten.",[626,671,672,676],{},[185,673,675],{"href":674},"\u002Fdocs\u002Fschemas\u002Fdiscriminated-unions","Discriminated unions",": the schema feature that benefits most from the tight side of this contract.",[678,679,680],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":73,"searchDepth":105,"depth":105,"links":682},[683,684,685,687,688,689],{"id":62,"depth":105,"text":63},{"id":216,"depth":105,"text":217},{"id":377,"depth":105,"text":686},"defaultValues works the same way",{"id":478,"depth":105,"text":479},{"id":604,"depth":105,"text":605},{"id":650,"depth":105,"text":651},"Types follow the form through every state. In-flight reads are wide enough to hold whatever the user is actually typing; handleSubmit hands you the schema's validated output with literals narrowed, refinements honored, and discriminated unions discriminating.","md",{},[694,697,700,702],{"label":695,"value":696},"Category","Concept",{"label":698,"value":699,"kind":30},"In-flight","form.values, defaultValues, setValue",{"label":701,"value":49,"kind":30},"Validated",{"label":703,"value":704},"Boundary","Schema parse","\u002Fdocs\u002Freading-the-form\u002Ftype-safety",{"title":5,"description":690},null,"docs\u002Freading-the-form\u002Ftype-safety","qcj-BM0G1F8WAGSGKyLwFU_meNeYE0U8bPDsqDES9kc",1780949758815]