Skip to content

Revamp useForm's generic types across adaptors #2335

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

Spice-King
Copy link
Contributor

What started as a rework to solve allowing otherwise valid interfaces being provided directly via useForm<SomeInterface>({...}) (fixing #2188), ended up as a total rework of the generic types they use and their uses and expanding on the work I did in #1649. Hell of a nerd snipe on myself thinking it could be solved.

All adaptors no longer have their own bespoke but functionally identical FormDataType, and now use one that I added to core. It should reject things that are invalid to be in the form (Functions come to mind on the typings, but anything that's not basic JSON data), which might cause some type issues for end users if they were doing something invalid to start with. If this is too tight, it can be made more permissive if that's something people want later.

Updated FormDataValues and FormDataKeys to dive down into arrays and TypeScript tuples correctly. I did quickly test all the playgrounds to make sure the adaptors use it for things like defaults/setDefault, setData/setStore, or reset. All of them work correctly even with arrays, with the one rub being that reset has a footgun with keys nested inside an array (if a default for the index on the array does not have a value set, it's behaviour is not well defined, ranging from setting the index to undefined or not resetting a value at all).

A type for form errors was extracted to core as Partial<Record<FormDataKeys<TForm>, string>> everywhere was verbose.

Relevant types have been exported on the core package for any third party client adaptors to use.

Demo of types
import { useForm } from '@inertiajs/vue3'

interface NestedType {
  id: number
  name: string
}
interface TestType {
  string: string
  number: number
  nested: NestedType
  array: NestedType[]
  tuple: [string, number, NestedType]
  typeObject: { union: 'foo' | 'bar' }
}
const form = useForm<TestType>({[...]})

form.string
//   ^? (property) TestType.string: string
form.number
//   ^? (property) TestType.number: number
form.nested
//   ^? (property) TestType.nested: NestedType
form.nested.id
//          ^? (property) NestedType.id: number
form.array[1].name
//            ^? (property) NestedType.name: string
form.tuple[0]
//         ^? (property) 0: string
form.tuple[1]
//         ^? (property) 1: number
form.tuple[2]
//         ^? (property) 2: NestedType
form.typeObject.union
//              ^? (property) union: "foo" | "bar"
form.errors['nested.name']
//           ^? (property) "nested.name"?: string
form.errors['tuple.2.name']
//          ^? (property) "tuple.2.name"?: string
form.errors['typeObject']
//          ^? (property) "typeObject"?: string
form.errors['typeObject.union']
//          ^? (property) "typeObject.union"?: string
form.reset('tuple.2.name')
form.defaults('tuple.0', 'newString')
form.defaults('tuple.1', 2)
form.defaults('tuple.2', { id: 1, name: 'Types!' })
form.defaults('typeObject.union', 'foo')

I expect this is not a breaking type change for either users or third party client adaptors, unless they were technically invalid to start with. I could be wrong though. The form error type might be wrong if a server side adaptor does not follow what Laravel's error bag does for nested data structures (IE: array.0.key), but it's not like it was being handled correctly before anyway. This is about two days of head bashing and checking done, but open to people poking holes where they can.

@Spice-King
Copy link
Contributor Author

42fbe8c adds the ability to override the error type. This was sparked by a request from @RobertBoes to allow an array of strings from the backend, rather than just one, or an mapping of named rules to human descriptions. This allows for greater flexibility for backends.

@Spice-King Spice-King closed this May 6, 2025
@Spice-King Spice-King reopened this May 6, 2025
@Andyuu
Copy link

Andyuu commented May 15, 2025

Taking a quick look at this, shouldn't the return type of transform be a FormDataType instead of any object as it must be valid at that point to send to the server?

Another thing of note, my use case doesn't look like it'll ever be directly supported, which is fine:

    const form = useForm({
        bornOn: undefined as Optional<Dayjs>,
    });

    form.transform(({ bornOn }) => ({
        bornOn: bornOn?.format('YYYY-MM-DD'),
    }));

Basically making my working data 'invalid' but turning it into something valid for submission. I'm hoping there is an elegant way I can do this without sacrificing all other protections.

My first thought would be to allow module augmentation on FormDataConvertible like this:

declare namespace FormData {
  interface ConvertibleValueMap {
    Blob: Blob;
    FormDataEntryValue: FormDataEntryValue;
    Date: Date;
    boolean: boolean;
    number: number;
    null: null;
    undefined: undefined;
  }
}

export type FormDataConvertibleValue = FormData.ConvertibleValueMap[keyof FormData.ConvertibleValueMap];

So consumers can add their own data types that will correctly serialize (or intend to always transform) themselves

declare namespace FormData {
  interface ConvertibleValueMap {
    // Add custom types
    Dayjs: Dayjs;
  }
}

This looks like it could be doable with this approach. There might be some better ways, but let me know what you think

@Spice-King
Copy link
Contributor Author

Taking a quick look at this, shouldn't the return type of transform be a FormDataType instead of any object as it must be valid at that point to send to the server?

To be blunt, I kinda considered it out of scope, since there is no good way to deal with the non-sense people can get up to with transform.

    const form = useForm({
        bornOn: undefined as Optional<Dayjs>,
    });

    form.transform(({ bornOn }) => ({
        bornOn: bornOn?.format('YYYY-MM-DD'),
    }));

This above largely why, though, does not full show why. There is nothing stopping someone from changing the shape of the object inside transform.

const form = useForm({ bornOn: undefined as Optional<Dayjs> });
form.transform(({ bornOn }) => ({
       myDate: bornOn?.format('YYYY-MM-DD'),
}));

form;
// ^? const form: InertiaFormProps<{ bornOn: Optional<Dayjs> }>
form.errors;
//   ^? (property) InertiaFormProps<{ bornOn: Optional<Dayjs> }>.errors: Partial<Record<"bornOn", string>>

// Validation errors no longer align with the form's own data
console.log(form.errors)
//  We end up with errors in the shape of Partial<Record<"myDate", string>>

transform can explicitly alter the data before it's sent to the server, but type wise, it can implicitly alter the data shape sent to the server, which has the knock on effect of the backend probably offering errors in that data shape's footprint. Personally, I think this is a valuable escape hatch, but otherwise something that's footgun that's impossible to handle correctly. You are ending up with overriding stuff on the front end and the back end to keep stuff in-line, which is just messy.

If there was a way to force the transform method to only be valid when chained directly off of useForm (IE const form = useForm({val: 1}).transform((d) => ({...d, random: Math.random()}) where random is replaced with something more meaningful), then I'd be able to properly type errors based off the return type of the transform call back. But that's just not something that Typescript offers, and neither does it allow changing a type because a function was called (totally ignoring the fact that one could call it multiple times or provide a random function out of a set to it).

Basically making my working data 'invalid' but turning it into something valid for submission. I'm hoping there is an elegant way I can do this without sacrificing all other protections.

My first thought would be to allow module augmentation on FormDataConvertible like this:

declare namespace FormData {
  interface ConvertibleValueMap {
    Blob: Blob;
    FormDataEntryValue: FormDataEntryValue;
    Date: Date;
    boolean: boolean;
    number: number;
    null: null;
    undefined: undefined;
  }
}

export type FormDataConvertibleValue = FormData.ConvertibleValueMap[keyof FormData.ConvertibleValueMap];

So consumers can add their own data types that will correctly serialize (or intend to always transform) themselves

declare namespace FormData {
  interface ConvertibleValueMap {
    // Add custom types
    Dayjs: Dayjs;
  }
}

This looks like it could be doable with this approach. There might be some better ways, but let me know what you think

That's a fair thought and all, but disagree somewhat. I don't think there should be a global way to register that a complex type is valid in a form without providing a way to register a sort of global handler to guarantee it will serialize correctly without manual intervention every time it's used with transform or something of the like. As this PR was aimed only at pure type level improvements, such a thing is out of scope for this PR in my eyes.

As an aside, is there a reason you are so fixed on keeping a DateJS instance inside a form, instead say of a Date or raw string? If I needed a enhanced Date instance (Luxon is my pick for that at least) for display or added validation reasons, I'd be using Vue's computed ref type to compute a clean instance of said enhanced Date, like const bornOnValidationHelper = computed(() => dayjs(form.bornOn)) which would only be recreated when bornOn changes. I believe that React should offer something similar in functionality.

@Andyuu
Copy link

Andyuu commented May 16, 2025

Thanks for your detailed response. I definitely see your reasoning around the typing of transform.

As for my usage of fancy date objects - the primary reason is ergonomics. The date picker library I use will happily take and spit out dayjs instances, and so has every form state library I've used in the past, making it quick to manipulate and format. This being the one exception is kind of a pain.

I can see how the design of this library makes it very difficult to safely type, but I feel that module augmentation firmly lies in "I know what I'm doing" territory. Other packages I use such as mui-x rely heavily on this for easy customisation, but very rarely is module augmentation completely "safe", so I don't believe a feature like this would need any guard rails to guarantee anything. As with transform, it can act as an escape hatch to work around specific problems.

I know I'm barking up the wrong tree here, but I'd like to hear other opinions before deciding to push the idea. Thanks once again for your efforts.

@pascalbaljet
Copy link
Member

I expect this is not a breaking type change for either users or third party client adaptors, unless they were technically invalid to start with. I could be wrong though. The form error type might be wrong if a server side adaptor does not follow what Laravel's error bag does for nested data structures (IE: array.0.key), but it's not like it was being handled correctly before anyway. This is about two days of head bashing and checking done, but open to people poking holes where they can.

Thanks for this, @Spice-King! Just wanted to let you know that I'm reviewing it now, but it might take some time as it's quite complicated 😬

@Spice-King
Copy link
Contributor Author

Spice-King commented Jun 18, 2025

Thanks for this, @Spice-King! Just wanted to let you know that I'm reviewing it now, but it might take some time as it's quite complicated 😬

Yea, sorry about that @pascalbaljet, TS magic is like that. Will recommend going commit by commit, though a high level summery of the black magic in FormDataValues<T> is that it either returns T[key] for each key of T if it matches the structure what can be sent or never if it does not. More or less counts on T extends never causing TS to throw an error, making a type nested in the TForm stick out if invalid. interfaces work because, when valid, it's checking against the exact same data shape, just in type form.

If you want to pick my brain on any given bit, I am on the Discord, rather than spamming the emails of those who are following.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants