T
TanStack2mo ago
wise-white

Union type issues using `withForm`

I almost got the pattern I like working, it works in runtime. But can't fix a few couple of type issues I'm running into I have a Contact domain that has a create/patch endpoint I'm trying to create a shared component ContactFormFields that can be used in both the create and patch contexts. I can check in runtime if we are doing a create or patch and render conditionally These are my types
import type { MergeExclusive, RequiredDeep } from 'type-fest'

export type EditType = ContactType & ContactPatchType // ContactType has readonly properties like `id`, ContactPatchType has the properties that are editable
export type CreateType = RequiredDeep<ContactDataType> // ContactDataType contains properties that are used for initial create, so no `id` for example
type EitherType = MergeExclusive<CreateType, EditType>
import type { MergeExclusive, RequiredDeep } from 'type-fest'

export type EditType = ContactType & ContactPatchType // ContactType has readonly properties like `id`, ContactPatchType has the properties that are editable
export type CreateType = RequiredDeep<ContactDataType> // ContactDataType contains properties that are used for initial create, so no `id` for example
type EitherType = MergeExclusive<CreateType, EditType>
The shared component
export const ContactFormFields = withForm({
// These values are only used for type-checking, and are not used at runtime
// This allows you to `...formOpts` from `formOptions` without needing to redeclare the options
defaultValues: {} as EitherType,
props: {} as { type: 'create' | 'patch' },
render: function Render({ form, type }) {
...
export const ContactFormFields = withForm({
// These values are only used for type-checking, and are not used at runtime
// This allows you to `...formOpts` from `formOptions` without needing to redeclare the options
defaultValues: {} as EitherType,
props: {} as { type: 'create' | 'patch' },
render: function Render({ form, type }) {
...
My edit form - https://pastes.io/react-contactviewform-with-patch-form-type-error-comment And there is a similar create form(leaving out cause this is getting too long for Discord) I have 2 problems Problem 1 In my edit and create forms I get a type error passing form to ContactFormFields (this is for <ContactFormFields type="patch" form={form} />) https://pastes.io/type-mismatches-for-formasyncvalidateorfn-and-formvalidateasyncfn Problem 2 In the ContactFormFields
<form.AppField name="id">
//Would expect a type error here, type should be `string | undefined`, since `id` should only be defined if we are in a `patch` context. But the type is `string` currently!
{(field) => <div>ID: {field.state.value.length}</div>}
</form.AppField>
<form.AppField name="id">
//Would expect a type error here, type should be `string | undefined`, since `id` should only be defined if we are in a `patch` context. But the type is `string` currently!
{(field) => <div>ID: {field.state.value.length}</div>}
</form.AppField>
11 Replies
rare-sapphire
rare-sapphire2mo ago
withForm is very strict because its use case is to split a single form across multiple components / files if you have two forms that want to share data, it gets more complicated. You can structure the data around being compatible as one form, but another way to achieve this is to use withFieldGroup for common sections instead
wise-white
wise-whiteOP2mo ago
Just tried doing it with withFieldGroup instead. It's more verbose, fixes the Problem 1 type error. But problem 2 still exists. Even though EitherType has a property of id: string | undefined
<group.AppField name="id">
{(field) => <div>ID: {field.state.value.length}</div>}
</group.AppField>
<group.AppField name="id">
{(field) => <div>ID: {field.state.value.length}</div>}
</group.AppField>
This doesn't produce a type-error, instead fails at runtime. In my attempts at trying to understand where the type is getting messed up. Going through the internal form types FieldState<TParentData, TName extends DeepKeys<TParentData>, TData extends DeepValue<TParentData, TName>, ... TData controls the type of the field It seems like DeepValue can't handle union types
const testObj: { id: string } | { id: undefined } = Math.random()
? { id: undefined }
: { id: '' }

type idField = DeepValue<typeof testObj, 'id'>
// Type error - TS2322: Type undefined is not assignable to type string
const a: idField = undefined
const b: idField = 'zzz'
const testObj: { id: string } | { id: undefined } = Math.random()
? { id: undefined }
: { id: '' }

type idField = DeepValue<typeof testObj, 'id'>
// Type error - TS2322: Type undefined is not assignable to type string
const a: idField = undefined
const b: idField = 'zzz'
I've managed to make the API work using withForm In my withForm component
export type EditType = ContactType & ContactPatchType
export type CreateType = RequiredDeep<ContactDataType>
export type EitherType = AllUnionFields<CreateType | EditType>

export const ContactFormFields = withForm({
defaultValues: {} as EitherType,
...
export type EditType = ContactType & ContactPatchType
export type CreateType = RequiredDeep<ContactDataType>
export type EitherType = AllUnionFields<CreateType | EditType>

export const ContactFormFields = withForm({
defaultValues: {} as EitherType,
...
Edit view
const form = useTypeboxForm({
defaultValues: {
id: contact.id,
...
} satisfies EditType as EitherType,
const form = useTypeboxForm({
defaultValues: {
id: contact.id,
...
} satisfies EditType as EitherType,
Create view
const defaultValues = {
company_name: '',
...
} satisfies CreateType as EitherType

function RouteComponent() {
const form = useTypeboxForm({
defaultValues,
...
const defaultValues = {
company_name: '',
...
} satisfies CreateType as EitherType

function RouteComponent() {
const form = useTypeboxForm({
defaultValues,
...
My understanding is that the interior types of form don't like unions { id: string } | { id: undefined } This causes problems { id: string | undefined } This is ok My only question is, will this behavior change. I think its a really nice pattern that reduces boilerplate and maintains type-safety, compared to using withFieldGroup The only place the type isn't perfect, is the onSubmit handler returns EitherType. And that isn't 100% correct. If i could avoid casting to EitherType in the edit/create view's defaultValues that would be perfect.
rare-sapphire
rare-sapphire2mo ago
interesting … might be a problem with extracting it back out. I‘ll tinker with it later
wise-white
wise-whiteOP2mo ago
LMK if i can be any help. If I can get the union to work without merging it that would be great
rare-sapphire
rare-sapphire2mo ago
creating a GitHub issue would be nice. Here's the (slightly more internal) problem with the union's type:
type Example = { id: string } | { id: undefined };

type Test = DeepKeysAndValues<Example>
// ^? { key: "id"; value: string; }
type Example = { id: string } | { id: undefined };

type Test = DeepKeysAndValues<Example>
// ^? { key: "id"; value: string; }
wise-white
wise-whiteOP2mo ago
Will create the issue. Anything particular to document in the issue? Or a gist of what was discussed here
rare-sapphire
rare-sapphire2mo ago
just how you encountered it, and upon investigating you found this issue
wise-white
wise-whiteOP2mo ago
Is a repro needed?
rare-sapphire
rare-sapphire2mo ago
we have a different issue with DeepKeys and DeepValue, so it's best to have an issue to make sure a PR could fix both at once what I sent is already repro enough.
wise-white
wise-whiteOP2mo ago
GitHub
Union types are not handled correctly · Issue #1813 · TanStack/form
Describe the bug The internal types of Tanstack Form, DeepValue and DeepKeysAndValues don&#39;t handle union types correctly and seem to collapse them. type Example = { id: string } | { id: undefin...
rare-sapphire
rare-sapphire2mo ago
thanks!

Did you find this page helpful?