Home > other >  Generic UseForm Hook
Generic UseForm Hook

Time:03-05

I'm trying to create a useForm hook with generics and Typescript. I'd like the consumer to be able to enter an interface and initial state, and then for the hook to dynamically create a new state with added validation fields.

For example, the user passes in:

const {formState, handleSubmit, updateField} = useForm<{
        email: string;
    }>({
        email: "",
    });

And behind the scenes the hook would create a state like this:

{
  email: {
    value: "",
    validationMessage: "",
    validationType: "none" // as opposed to "success" or "error"
  }
}

I'm trying to achieve this with reduce, with the following code:

interface FormField {
    value: string,
    validationType: ValidationProperty,
    validationMessage: string 
}

function isInInitialState<T>(val: string | keyof T, vals: T): val is keyof T {
  return typeof val === "string" && Object.keys(vals).includes(val)
}

function useForm<T>(initialFormState: T) {

  const [formState, setFormState] = useState<Record<keyof T, FormField>>(Object.keys(initialFormState).reduce<Record<keyof T, FormField>>((previous, current) => {
    return {
      ...previous,
      [current]: {
        value: isInInitialState(current, initialFormState) ? previous[current] : "",
        validationType: "none",
        validationMessage: ""
      }
    }
  }, {}));

...rest of hook.

The problem for Typescript is that my starting value of an empty object is not the same shape as T, and so the compiler throws an error. Likewise, if I populate the starting value with the same values as initialFormState, I have the same problem.

In short, how can I tell Typescript that I'm going to definitely end up with Record<keyof T, FormField>>, and so it doesn't need to worry about the starting value?

CodePudding user response:

I think you can use type assertions for your issue.

Sometimes typescript is not able to infer types properly, this is what happen when you call your reduce function. So you may tell typescript what will be the expected return for your constant.


type ValidationProperty = 'none' | 'other'

interface FormField {
  value: string
  validationType: ValidationProperty
  validationMessage: string
}

type FormState<T> = Record<keyof T, FormField>

function useForm<T>(initialFormState: T) {
  const keys = Object.keys(initialFormState)
  const defaultFormState = keys.reduce(
    (acc, key) => ({
      ...acc,
      [key]: {
        value: initialFormState[key],
        validationType: 'none',
        validationMessage: ''
      }
    }),
    {}
  ) as FormState<T> // Type assertion here

  const [formState, setFormState] = useState<FormState<T>>(defaultFormState)

  return [formState]
}

const MyComponent = () => {
  const [form] = useForm({ email: 'string' })
  // form has the type FormState<{email: string}>
  return <div>Hello</div>
}

CodePudding user response:

Using a type assertion will be helpful in your case. The reason this will be necessary is that the return type of Object.keys() is always string[] in TypeScript. This is because TS is structurally-typed, and therefore objects can have excess properties beyond the ones you've defined in your type.

It is also useful to extract your transformation function in this case:

TS Playground

import {useState} from 'react';

type ValidationType = 'error' | 'none' | 'success';

type FormField = {
  validationMessage: string;
  validationType: ValidationType;
  value: string;
};

function transformObject <T extends Record<string, string>>(o: T): Record<keyof T, FormField> {
  return Object.keys(o).reduce((previous, key) => ({
    ...previous,
    [key]: {
      value: o[key as keyof T],
      validationType: 'none',
      validationMessage: '',
    }
  }), {} as Record<keyof T, FormField>);
}

function useForm <T extends Record<string, string>>(initialFormState: T) {
  const [formState, setFormState] = useState(transformObject(initialFormState));
  // rest of hook...
}

transformObject({email: 999}); // Desirable compiler error
//               ~~~~~
// Type 'number' is not assignable to type 'string'.(2322)

const transformed = transformObject({email: '[email protected]'}); // ok

console.log(transformed); // Result:
// {
//   "email": {
//     "value": "[email protected]",
//     "validationType": "none",
//     "validationMessage": ""
//   }
// }

  • Related