Typescript – Type a function that accepts an array, alters one of its properties, but still returns the same typed array

Tags: ,



I have form fields represented as objects that get mapped over and based upon the type returns React elements:

import { FieldProps } from "~types";

const fields: FieldProps[] = [
  { type: "text", name: "username", value: "", required: true, ...other props },
  { type: "password", name: "password", value: "", required: true, ...other props },
  ...etc
]

export default fields;

The problem I’m running into is I’m trying to validate the fields after a form is submitted and check for any errors in value:

 handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const { validatedFields, errors } = fieldValidator(this.state.fields);

    if(errors) {
      this.setState({ errors });
      return;
    }

    ...etc
 }

but this reusable validate function has a ts error:

import isEmpty from "lodash.isempty";

/**
 * Helper function to validate form fields.
 *
 * @function
 * @param {array} fields - an array containing form fields.
 * @returns {object} validated/updated fields and number of errors.
 * @throws {error}
 */

const fieldValidator = <
 T extends Array<{ type: string; value: string; required?: boolean }>
>(
  fields: T,
): { validatedFields: T; errors: number } => {
  try {
    if (isEmpty(fields)) throw new Error("You must supply an array of form fields to validate!");
    let errorCount: number = 0;

    // this turns the "validatedFields" array into an { errors: string; type: string; name: 
    // string; value: string; required?: boolean | undefined;}[] type, when it needs to be "T", 
    // but "T" won't be inferred as an Array with Object props: type, value, required, value defined within it
    const validatedFields = fields.map(field => {
      let errors = "";
      const { type, value, required } = field;
      if ((!value && required) || (isEmpty(value) && required)) {
        errors = "Required.";
      } else if (
        type === "email" &&
        !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,4}$/i.test(field.value)
      ) {
        errors = "Invalid email.";
      }

      if (errors) errorCount += 1;

      return { ...field, errors };
    });

    return { validatedFields, errors: errorCount }; // TS error here
  } catch (err) {
    throw String(err);
  }
};

export default fieldValidator;

Since validatedFields turns into:

{
    errors: string;
    name: string;
    value: string;
    type: string;
    required: boolean;
}[]

and it’s returned { validatedFields, errors }, it throws this TS error:

Type '{ errors: string; type: string; value: string; required?: boolean | undefined; }[]' is not assignable to type 'T'.
  '{ errors: string; type: string; value: string; required?: boolean | undefined; }[]' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ type: string; value: string; required?: boolean | undefined; }[]'.ts(2322)
index.ts(17, 6): The expected type comes from property 'validatedFields' which is declared here on type '{ validatedFields: T; errors: number; }'

Is there a way to infer T as an array of objects that expects at least 4 (or more) properties, but returns the same typed array (just with an updated errors property)?

Typescript Playground

Answer

I managed to figure it out by extending T to any[] and then defining the result of validatedFields as T to preserve the passed in types:

const fieldValidator = <
  T extends any[]
>(
  fields: T,
): { validatedFields: T; errors: number } => {
  try {
    if (!fields || fields.length < 0) throw new Error("You must supply an array of fields!");
    let errorCount: number = 0;

    const validatedFields = fields.map(field => {
      let errors = "";
      const { type, value, required }: Pick<BaseFieldProps, "type" | "value" | "required"> = field;
      if ((!value && required) || ((value && value.length < 0) && required)) {
        errors = "Required.";
      } else if (
        type === "email" &&
        !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,4}$/i.test(value)
      ) {
        errors = "Invalid email.";
      } 

      if (errors) errorCount += 1;

      return { ...field, errors };
    }) as T;

    return { validatedFields, errors: errorCount };
  } catch (err) {
    throw String(err);
  }
};

Typescript playground



Source: stackoverflow