Búsqueda de sitios web

typescript - ¿Cómo verificar e inferir dinámicamente los tipos de envío de formularios basándose en la estructura de un formulario?


Estoy trabajando en una función de validación de formularios en TypeScript donde los formularios consisten en múltiples secciones, y cada sección tiene un tipo específico para su respuesta (por ejemplo, una cadena, un número o un arreglo). Me gustaría comprobar dinámicamente si una presentación de formulario coincide con la estructura esperada y luego inferir los tipos correctos para los datos de la presentación para usarlos en el resto de la función.

Estructura de ejemplo:

type Section = 'name' | 'age' | 'nicknames';

type Form = readonly Section[];

// Map sections to their answer types
type AnswerForSection<Section> = Section extends 'age'
  ? number
  : Section extends 'name'
  ? string
  : Section extends 'nicknames'
  ? string[]
  : never;

// Expected submission shape for a form
type ValidFormSubmission<F extends Form> = {
  [Section in F[number]]: AnswerForSection<Section>;
};


// Examples of forms
const form1 = ['name', 'age'] as const;
const form2 = ['name', 'nicknames'] as const;

type Form1SubmissionType = ValidFormSubmission<typeof form1>;
// { name: string, age: number } - This is what I expect

Quiero escribir una función checkFormAnswers que:

  • Toma un formulario y una presentación.
  • Primero verifica si la presentación coincide con la forma esperada del formulario.
  • Si la verificación pasa, debería permitir que TypeScript infiera los tipos del envío del formulario (por ejemplo, el campo edad debería inferirse como número).

He definido un guardia de tipo para verificar si la presentación de un formulario coincide con la estructura esperada, y he añadido ese guardia de tipo al inicio de la función checkFormAnswers:

function satisfiesForm<F extends Form>(
  form: F,
  submissionData: Record<string, any>,
): submissionData is ValidFormSubmission<F> {
  * Implementation details not important for this question *
}

function checkFormAnswers<F extends Form>(
  form: F,
  submission: Record<string, any>,
) {
  if (satisfiesForm(form, submission)) {
    form.forEach((question) => {
      if (question === 'age') {
      // I want TS to infer that submission has an 'age' field because the form does
      // and the submission satisfies the form
        const age = submission['age']; 
        console.log('Age is:', age); // I want TS to infer that age is a number
      }
    });
  } else {
    console.error('Invalid form submission');
  }
}

Pero Typescript no parece inferir los tipos correctamente después del guardia. Obtengo el siguiente error de ts:

Element implicitly has an 'any' type because expression of type '"age"' can't be used to index type 'ValidFormSubmission<F>'. Property 'age' does not exist on type 'ValidFormSubmission<F>'.

Puedo deshacerme de este error editando checkFormAnswers a:

function checkFormAnswers<F extends Form>(
  form: F,
  submission: Record<string, any>,
) {
  if (satisfiesForm(form, submission)) {
    form.forEach((question) => {
      const key = question as F[number];
      if (key === 'age') {
        const age = submission[key];
        console.log('Age is:', age);
      }
    });
  } else {
    console.error('Invalid form submission');
  }
}

Pero entonces la edad se infiere como string | number | string[]

¿Hay una manera de lograr lo que estoy intentando hacer aquí?

Editar: Seguimiento Mi estructura de formulario real es un poco más compleja:

interface BaseSection {
  readonly id: string;
}
interface TextSection extends BaseSection {
  type: 'text';
}
interface NumericSection extends BaseSection {
  type: 'numeric';
}
...
type Section = TextSection | NumericSection | MultipleChoiceSection;

type Form = readonly Section[];

type AnswerForSection<S extends Section> = S['type'] extends 'numeric'
  ? number
  : S['type'] extends 'text'
  ? string
  : S['type'] extends 'multiple-choice'
  ? string[]
  : never;

type AnswerForSections<Sections extends readonly Section[]> = {
  [K in Sections[number] as K['id']]: AnswerForSection<K>;
};

type ValidFormSubmission<F extends Form> = AnswerForSections<F>;

const form1 = [
  { id: 'name', type: 'text' },
  { id: 'age', type: 'numeric' },
] as const;

const form2 = [
  { id: 'name', type: 'text' },
  { id: 'favorite-foods', type: 'multiple-choice' },
] as const;

type Form1SubmissionType = ValidFormSubmission<typeof form1>;
// {
//   name: string;
//   age: number;
// }

¿Es posible inferir el tipo de esto? Playground

Respuesta #1

La regla de los genéricos dice que un parámetro genérico debe usarse más de una vez. Si sigues la regla con

function checkFormAnswers(
  form: Form,
  submission: Record<string, any>,
) 

entonces funciona bien: Playground

El problema con el genérico podría ser (no soy un experto) que lo EXTENDES de Form, por lo que podrías proporcionar un tipo MÁS AMPLIO. Por ejemplo, ['name'] & ['test'] extiende Form. No es un tipo significativo pero sí válido. Así que tu firma de índice [Section in F[number]] con este tipo más amplio no permite que TS infiera la relación entre las claves de las propiedades y sus valores, por lo tanto age=string | number | string[].

Pero sugiero aplicar una tipificación más atómica/precisa y mi regla e intuición es no pasar tipos de arreglo como parámetros genéricos. Más bien, pasar los tipos de los elementos del arreglo. Simplificar el código en general nos da:

type AnswerForSection = {
  name: string;
  age: number;
  nicknames: string[]
}

type Section = keyof AnswerForSection;

type Form = readonly Section[];

// Expected submission shape for a form
type ValidFormSubmission<S extends Section> = {
  [Section in S]: AnswerForSection[Section]
};

// Examples of forms
const form1 = ['name', 'age'] as const;
const form2 = ['name', 'nicknames'] as const;

type Form1SubmissionType = ValidFormSubmission<typeof form1[number]>;
// { name: string, age: number } - This is what I expect

declare function satisfiesForm<F extends Form>(
  form: Form,
  submissionData: Record<string, any>,
): submissionData is ValidFormSubmission<F[number]>

function checkFormAnswers<F extends Form>(
  form: F,
  submission: Record<string, any>,
) {
  if (satisfiesForm(form, submission)) {
    form.forEach((question) => {
      if (question === 'age') {
      // I want TS to infer that submission has an 'age' field because the form does
      // and the submission satisfies the form
        const age = submission[question]; 
        console.log('Age is:', age); // I want TS to infer that age is a number
      }
    });
  } else {
    console.error('Invalid form submission');
  }
}

lo cual funciona bien: Playground

Dado que usamos Section como un parámetro genérico para ValidFormSubmission y es una unión de literales de cadena, parece que TS puede usarlo para la protección de tipos y para indexar el tipo de la protección de tipos resultante.

ACTUALIZACIÓN

Lo más cercano que he logrado conseguir:

Parque infantil

interface BaseSection {
  readonly id: string;
}
interface TextSection extends BaseSection {
  type: 'text';
}
interface NumericSection extends BaseSection {
  type: 'numeric';
}
interface MultipleChoiceSection extends BaseSection {
  type: 'multiple-choice';
}
type Section = TextSection | NumericSection | MultipleChoiceSection;

type Form = readonly Section[];

type AnswerForSection = {
  numeric: number,
  text: string,
  'multiple-choice': string[]
}

type AnswerForSections<S extends Section> = {
  [K in S['id']]: AnswerForSection[S extends {id: K} ? S['type']: never];
};

const form1 = [
  { id: 'name', type: 'text' },
  { id: 'age', type: 'numeric' },
] as const;

const form2 = [
  { id: 'name', type: 'text' },
  { id: 'favorite-foods', type: 'multiple-choice' },
] as const;

type Form1SubmissionType = AnswerForSections<typeof form1[number]>;
// {
//   name: string;
//   age: number;
// }

type Form2SubmissionType = AnswerForSections<typeof form2[number]>;

declare function satisfiesForm<S extends Section>(
  section: S,
  submissionData: Readonly<Record<string, any>>,
): submissionData is AnswerForSections<S>;

function checkFormAnswers<F extends Form>(form: F, submission: Record<string, any>) {
  for(const section of form){
    if (section.type === 'numeric' && satisfiesForm(section, submission)) {
        const answer = submission[section.id]; // I want TS to infer that is this a number
    }
  }
}