form icon indicating copy to clipboard operation
form copied to clipboard

Infer form data type from validator instead of default values

Open sebakerckhof opened this issue 6 months ago • 2 comments

Currently the type of the form data seems to be inferred from the default values instead of the validator type. However, the validator is typically more complete/extended and since it's used for validation, it should always result in the correct type...

Currently, when I do something like (using zod in the example):

  const optionsForm = useAppForm({
    defaultValues: { foo: '' },
    validators: {
      onChange: z.object({ foo: z.string().optional() }),
    },
  });

This gives me a ts error, because it says the validation schema has foo as optional, while the default values have it filled in.

Another example with an enum type:

  const optionsForm = useAppForm({
    defaultValues: { foo: 'a' },
    validators: {
      onChange: z.object({ foo: z.enum(['a', 'b'])}),
    },
  });

This gives me a ts error because in the default values foo is infered as string, but the validator only accepts the more narrow 'a' | 'b' as type.

Since useAppForm takes a very large number of generics, manually specifying these isn't really a good alternative.

sebakerckhof avatar Jun 17 '25 07:06 sebakerckhof

I have two concerns with this request:

  1. What would be the inferred data type from these validators?
// These are all valid validators in TSF v1
validators: {
  onMount: z.object({ name: z.string().min(1) }),
  onChange: () => 'Consistent error',
  onBlur: ({ value }) => value.name.length === 0 ? 'Too short' : null,
  onSubmit:  ({ formApi }) => {
     if (externalCondition) {
        return formApi.parseValuesWithSchema(
            z.object({ name: z.string().min(1) })
        )
     }
  } 
}
  1. Which one takes priority?
validators: {
  onMount: z.object({ name: z.string().min(1) }),
  onChange: z.object({ name: z.number() })
}

For now, since both defaultValues and validators are optional properties, both are considered for type inference. TypeScript can properly typecheck it by extracting defaultValues like so:

const schema = z.object({ foo: z.string().optional() });

// Note: input, NOT infer.
type SchemaValues = z.input<typeof schema>;
const emptyForm: SchemaValues = {
   foo: undefined;
}

const form = useAppForm({
   defaultValues: emptyForm
})

LeCarbonator avatar Jun 17 '25 12:06 LeCarbonator

Related issues:

#1580 #1579 #1573

LeCarbonator avatar Jun 17 '25 12:06 LeCarbonator

We tried to migrate from react-hook-form to this library, however this issue caused a lot of troubles during migration so we had to stop. Using default, optional or just using drizzle-zod or similar libs cause a lot of troubles with typescript because defaultValues override the types from the schema validator. we tried to use some hacks to force it to work. However this is a critical issue for wider adoption of this library. At least we need docs or other official workaround for this problem

Zombobot1 avatar Jun 19 '25 18:06 Zombobot1

We tried to migrate from react-hook-form to this library, however this issue caused a lot of troubles during migration so we had to stop. Using default, optional or just using drizzle-zod or similar libs cause a lot of troubles with typescript because defaultValues override the types from the schema validator. we tried to use some hacks to force it to work. However this is a critical issue for wider adoption of this library. At least we need docs or other official workaround for this problem

I have found that docs around zod integration are basically non-existent

erasebegin avatar Jun 22 '25 18:06 erasebegin

Yes, the documentation could (and should!) be more thorough for zod, valibot, arktype and other standard schema libraries. However, this comment should be addressed before the feature request is considered. For now, it serves as a request for documentation only.

LeCarbonator avatar Jun 25 '25 10:06 LeCarbonator

We encountered the same problem. It seems to us that inferring from defaultValues only is not convenient and not obvious. We end up writing more code than we'd like to. We're considering making generics for useForm (and probably useOptions) to automatically infer from all input options by finding their common interface type. Would you be willing to accept such changes? If so, would you want to keep the generics in useForm for users the same (same sequence and number of parameters) for backward compatibility?

Tiikara avatar Jul 11 '25 20:07 Tiikara

We encountered the same problem. It seems to us that inferring from defaultValues only is not convenient and not obvious. We end up writing more code than we'd like to. We're considering making generics for useForm (and probably useOptions) to automatically infer from all input options by finding their common interface type. Would you be willing to accept such changes? If so, would you want to keep the generics in useForm for users the same (same sequence and number of parameters) for backward compatibility?

As the TypeScript section of the documentation says, type changes are not considered breaking changes. If you have a solution to propose, go for it.

Some things to note about it:

  • The previous comment is still unresolved. It must be addressed in the solution.
  • Some users requested defaultValues to be the source of truth and schemas should infer from it. Others (such as this issue) wish the schema to be the source of truth and defaultValues to infer from it. A solution that can handle both would be ideal, but it is likely not possible. If you have ideas, feel free to try.
  • While type changes aren't breaking changes, existing type unit tests should produce the same results.

LeCarbonator avatar Jul 12 '25 15:07 LeCarbonator

@LeCarbonator

We're currently experimenting with this implementation approach.

Here's what we're getting now:

useForm({
  defaultValues: {
    name: '',
    case1: '',
  },
  validators: {
    onMount: z.object({ name: z.string().min(1), case1: z.string().nullable() }),
    onChange: () => 'Consistent error',
    onBlur: ({ value }) => (value.name.length === 0 ? 'Too short' : null),
    onSubmit: ({ formApi }) => {
      return formApi.parseValuesWithSchema(
        z.object({ name: z.string().min(1), case1: z.string().nullable() }),
      )
    },
  },
})

You must specify in the FormApi validation with all fields z.object({ name: z.string().min(1), case1: z.string().nullable() }).

In this case, the type Data = {name: string, case1: string | null} and the code is valid.

Currently, with this pattern:

useForm({
  validators: {
    onSubmit: ({ formApi }) => {
      return formApi.parseValuesWithSchema(
        z.object({ name: z.string().min(1) }),
      )
    },
  },
})

The types are not being inferred (though I'm not sure what practical use case this would have without defaultValues). Types are inferred from both defaultValues and validator schemas simultaneously.

Here's an example:

const onChangeSchema = z.object({
  nullableField1: z.string().nullable(),
  booleanField: z.boolean(),
  nullableField2: z.string().nullable(),
  optionalField: z.string().optional(),
  nullishField: z.string().nullish(),
  fieldOnChange: z.string(),
})

const onBlurSchema = z.object({
  booleanField: z.boolean(),
  nullableField1: z.string().nullable(),
  nullableField3OnBlur: z.string().nullable(),
})

const form = useForm({
  defaultValues: {
    defaultValuesOnlyField: '',
    booleanField: true,
    nullableField1: '',
    nullableField2: null,
    nullableField3OnBlur: '',
    fieldOnChange: '',
  } as const,
  validators: {
    onMount: z.object({ nullableField1: z.string().min(1).nullable() }),
    onChange: onChangeSchema,
    onBlur: onBlurSchema,
    onSubmit: ({ value }) => {
      expectTypeOf(value.defaultValuesOnlyField).toEqualTypeOf<''>()
      expectTypeOf(value.booleanField).toEqualTypeOf<boolean>()
      expectTypeOf(value.nullableField1).toEqualTypeOf<string | null>()
      expectTypeOf(value.nullableField2).toEqualTypeOf<string | null>()
      expectTypeOf(value.nullableField3OnBlur).toEqualTypeOf<string | null>()
      expectTypeOf(value.optionalField).toEqualTypeOf<string | undefined>()
      expectTypeOf(value.fieldOnChange).toEqualTypeOf<string>()
      expectTypeOf(value.nullishField).toEqualTypeOf<string | null | undefined>()
    },
  },
})

Here, Data has the type:

{
   defaultValuesOnlyField: ''
   booleanField: boolean
   nullableField1: string | null
   nullableField2: string | null
   nullableField3OnBlur: string | null
   fieldOnChange: string,
   nullishField: string | null | undefined
   optionalField: string | undefined
}

Non-optional types from validators must always be specified in defaultValues, while defaultValues can have additional fields not mentioned in validators.

I believe this is the intended behavior. This is exactly how the current implementation works. What do you think?

Tiikara avatar Jul 13 '25 03:07 Tiikara

Also, regarding this case:

validators: {
 onMount: z.object({ name: z.string().min(1) }),
 onChange: z.object({ name: z.number() })
}

I think this is simply an error. Does it really matter which validator the type inference comes from? The field should have a consistent type across all validators I think.

Tiikara avatar Jul 13 '25 03:07 Tiikara

@Tiikara Sounds great! So this implementation already has passing tests? Is there a place where I can view the code?

We're open for PRs, so even if it needs changing / is not implemented, feel free to create one to open discussion for it.

LeCarbonator avatar Jul 13 '25 08:07 LeCarbonator

@LeCarbonator

Currently, the implementation is quite prototypical. I'd like to refine it further before submitting a PR. Most tests are passing except for two. One test expects a Person type, but the type inference is resolving to the expanded fields of the Person object instead. The current implementation iterates through each field of the type, so there are areas for improvement. The second issue is that validators currently don't require validation of all fields. My understanding is that each validator should validate every field of the type. But I'm not sure about that. It would be good if you could clarify this point.

I'll improve the prototype to a more robust state and then submit a PR.

Tiikara avatar Jul 13 '25 13:07 Tiikara

This also seems to complicate things such as https://github.com/colinhacks/zod/issues/1206 and https://github.com/fabian-hiller/valibot/issues/1088 where you want your input type to be less strict than your output type (perhaps even makes it impossible - haven't found a proper solution yet myself and the underlying types make this quite an unpleasant experience).

pleunv avatar Jul 28 '25 15:07 pleunv

@pleunv

Zod definitely can manipulate that to be nullable on input and non-nullable on output - The issue here requests that it's part of the typing by default.

Here's the v4 code snippet you can use for that:


/**
 * Modifies the provided schema to be nullable on input, but non-nullable on output.
 */
function nullableInput<TSchema extends ZodType>(schema: TSchema) {
  return schema.nullable().transform((value, ctx) => {
    if (value === null) {
      ctx.addIssue({
        code: 'invalid_type',
        expected: schema._zod.def.type,
        input: null,
      });
      return z.NEVER;
    }
    return value;
  });
}

// Usage
const mySchema = z.object({
   selection: nullableInput(z.string())
})

type SchemaInput = z.input<typeof mySchema>;
// ^? { selection: string | null }
type SchemaOutput = z.output<typeof mySchema>;
// ^? { selection: string } 

More info from zod docs

LeCarbonator avatar Jul 28 '25 15:07 LeCarbonator

I ran into the problem with valibot and I had the impression it kept inferring the wrong type from defaultValues and thus ended up at this issue, but I might have to take a closer look and create a repo.

pleunv avatar Jul 30 '25 19:07 pleunv

@pleunv could be an issue with TypeScript where TS tries to infer that the assigned defaultValues is not reassigned and therefore narrows the type despite the annotation.

That's easy to check though, try refactoring the assignment to this to see:

// before - not sure how valibot exports its types, so Foo is the input type of valibot
const defaultValues: Foo = { /* ... */ }

// after - type safe, but assigns to ensure it's not narrowed preemtively by TS
const defaultValues = { /* ... */ } satisfies Foo as Foo

LeCarbonator avatar Jul 30 '25 19:07 LeCarbonator

The simplest solution is to use zod union:

z.union([z.string().length(0), z.string().min(10])

In this way zod will accept an empty string or else at least 10 characters. It is not uggly and works like a charm.

felipestanzani avatar Oct 04 '25 02:10 felipestanzani

Hey all, dropping a line here to explain why this cannot be done:

In order to support Standard Schema (IE: non-Zod usage). While I'd love to support this feature OOTB, we'd need some way to handle default values at runtime and Standard Schema does not have this capability.

We'd effectively need this: https://github.com/standard-schema/standard-schema/issues/11 to be implemented before we could consider handling things the way you'd want here.

As such, we're blocked upstream from moving forward here. I think I'll close this issue to do some cleanup on issues, but if https://github.com/standard-schema/standard-schema/issues/11 is completed, we can reopen and reconsider.

Sorry gang.

crutchcorn avatar Nov 30 '25 07:11 crutchcorn