[feedback] 100% typechecking for deep field names and values
So the philosophy docs state that
React Final Form provides strong typing via both Flow and Typescript to allow you to catch common bugs at coding time.
But this is far from 100% true; it's obvious that react final form uses dynamic field names that it can't typecheck, just like redux form. Hence I don't get compile time errors about using the wrong field names or expecting the field values to be a certain type. For example when I rename a field in refactoring, it would be nice to get compile errors about every place where I'm using the old field name. I've caused numerous regressions when refactoring complex forms because I can't get compile errors like this right now.
I mean it seems like you can pass a type parameter like <Field<string>> but doesn't seem like anything can typecheck that get(formValues, name) is guaranteed to be a string.
Have you thought about redesigning the API to make the values object tree 100% strongly typed? Flow and Typescript don't have a way to deep pick a property type via string path as far as I know so it seems like we would need to pass in some kind of getters/setters to Field instead. Checking top-level fields is currently possible with string names, but not fields inside nested objects and arrays.
Seems like we would have to bind values types to Form and Field components via an HoC like
type InitialUserFormValues = { username: string | null | undefined }
type ValidatedUserFormValues = { username: string }
const {Form, Field} = createForm<InitialUserFormValues, ValidatedUserFormValues>()
const validateUsername = (value: string | null | undefined): {error: string} | {value: string} => {
if (username == null) return {error: 'is required}
return {value: username}
}
const handleSubmit = async (values: ValidatedUserFormValues) => {
...
}
const MyForm = () => (
<Form onSubmit={handleSubmit}>
{/* not sure what form getters and setters for deep fields would have to take... */}
<Field name="username" component="input" validate={validateUsername} />
</Form>
)
As other people have joked, I think this isn't your final form 😂 This is still begging for a solution
I've been researching ways to typecheck deep paths...the following is not very elegant, but it works in Flow. Hopefully I can find some more elegant way to do it:
// @flow
type Values = {
bar: Array<{
baz: string,
}>,
}
type KeyType<T> = $Call<
(
(<T: Array<any>>(T) => number)
& (<T: {}>(T) => $Keys<T>)
& (<T: any>(T) => void)
),
T
>
const isValidIdentifier = (s: mixed) => typeof s === 'string' && /^[_a-z][_a-z0-9]*$/i.test(s)
class PathBuilder<T> {
parent: ?PathBuilder<any>
key: ?(string | number)
constructor(parent?: PathBuilder<any>, key?: string | number) {
this.parent = parent
this.key = key
}
get<K: KeyType<T>>(key: K): PathBuilder<$ElementType<T, K>> {
return new PathBuilder(this, key)
}
toString(): string {
const base = this.parent?.toString() || ''
const {key} = this
if (key == null) return base
if (typeof key === 'string' && /^[_a-z][_a-z0-9]*$/i.test(key)) {
return base ? `${base}.${key}` : key
}
return `${base}[${JSON.stringify(key)}]`
}
}
new PathBuilder<Values>().get('bar').get(2).get('baz').toString() // ✅
new PathBuilder<Values>().get('bar').get('foo').toString() // ❌
new PathBuilder<Values>().get('bar').get(2).get('qux').toString() // ❌
38: new PathBuilder<Values>().get('bar').get('foo').toString()
^ Cannot call `(new PathBuilder<...>()).get(...).get` with `'foo'` bound to `key` because string [1] is incompatible with number [2]. [incompatible-call]
References:
38: new PathBuilder<Values>().get('bar').get('foo').toString()
^ [1]
23: get<K: KeyType<T>>(key: K): PathBuilder<$ElementType<T, K>> {
^ [2]
40: new PathBuilder<Values>().get('bar').get(2).get('qux').toString()
^ Cannot call `(new PathBuilder<...>()).get(...).get(...).get` with `'qux'` bound to `key` because property `qux` is missing in object type [1]. [prop-missing]
References:
23: get<K: KeyType<T>>(key: K): PathBuilder<$ElementType<T, K>> {
^ [1]
This produces the expected errors on invalid paths, but the error messages are really confusing:
type DeepValue<Values, Path> = $Call<
(
// etc.
& (<A, B, C>([A, B, C]) => $ElementType<$ElementType<$ElementType<Values, A>, B>, C>)
& (<A, B>([A, B]) => $ElementType<$ElementType<Values, A>, B>)
& (<A>([A]) => $ElementType<Values, A>)
),
Path
>
const baz: DeepValue<Values, ['bar', 2, 'qu']> = 'test'
This is hopeless.
If you have to use react-final-form with TS, probably best to avoid nested data structures altogether.
Something like probably would work:
type DocumentFormState = {
[key: `${string}_fileName`]: string
[key: `${string}_expireAt`]: Date
}
const form = useForm<DocumentFormState>()
const id = "abcd-1234"
form.change(`${id}_expireAt`, new Date()) // type-checks
form.change(`${id}_expireAt`, "nope") // errors
But then you'd have to do your own parsing into data structures of course.
There's a huge disconnect between the run-time, which parses name="foo.bar" and the types that don't support it.
Types, as they are, in this library, do not work for nested data structures. 🫤
(This is probably half the reason why people are switching to react-hook-form - it has proper TS support.)