Búsqueda de sitios web

javascript - Genéricos en TypeScript, restricción para parámetros de función en objeto


Lo que quiero lograr:

Digamos que hay una configuración con un tipo como este:

type ExmapleConfig = {
    A: { Component: (props: { type: "a"; a: number; b: number }) => null };
    B: { Component: (props: { type: "b"; a: string; c: number }) => null };
    C: { Component: () => null };
};

entonces, hablando en general, algo con forma así:

type AdditionalConfigProps = {
    additionalConfigProp?: string;
    // + more additional props that don't have to be optional
};

type ReservedComponentProps = {
    reservedComponentProp: string;
};

type ComponentProps = ReservedComponentProps & Record<string, any>;

type Config = {
    [key: string]: {
        Component: (props: PropsShape) => JSX.Element;
    } & AdditionalConfigProps;
};

Me gustaría transformar una configuración así, pero:

  • conservar tipos estrictos para las claves ('A' | 'B' | 'C' en lugar de string)
  • preservar tipos estrictos para las props ({ type: "a"; a: number; b: number } en lugar de Record<string, any>)
  • asegúrate de que la función de transformación solo acepte configuraciones correctas, es decir:

    • tiene la propiedad Component, y todas las demás propiedades de AdditionalConfigProps con los tipos correctos,
    • no aceptará ninguna propiedad adicional además de las definidas en Component y las que están en AdditionalConfigProps,
    • La función Component debe poder aceptar un objeto similar a ComponentProps como primer argumento,

La transformación puede verse así:

const config = {
    A: { Component: (props: { type: "a"; a: number; b: number }) => <div>abc</div> };
    B: { Component: (props: { type: "b"; a: string; c: number }) => <div>abc</div>  };
    C: { Component: () => <div>abc</div>  };
};

/*
    Let's say that it will extract Components, and wrap them
    with additional function so void will be returned instead of JSX
*/
const transformedConfig = transformConfig(config);

// typeof transformedConfig
type ResultType = {
    A: (props: { type: "a"; a: number; b: number }) => void;
    B: (props: { type: "b"; a: string; c: number }) => void;
    C: () => void;
};

Por favor, note que:

  • Los tipos duros para las teclas 'A' | 'B' | 'C' fueron preservados
  • Se preservaron los tipos estrictos para 'props'

Enfoque que he probado:

import React from "react";

type AdditionalConfigProps = {
    additionalConfigProp?: string;
};

type ReservedComponentProps = {
    reservedComponentProp: string;
};

const CORRECT_CONFIG = {
    A: {
        Component: (props: { type: "a"; a: number; b: number }) => null,
        additionalConfigProp: "abc"
    },
    B: { Component: (props: { type: "b"; a: string; c: number }) => null },
    C: { Component: (props: { reservedComponentProp: "c"; a: string }) => null },
    D: { Component: (props: {}) => null },
    E: { Component: () => null }
};

const BAD_CONFIG = {
    // Missing Component or other required config prop
    A: {},
    // Bad additionalConfigProp
    B: { Component: () => null, additionalConfigProp: 123 },
    // Bad Component
    C: { Component: 123 },
    // Bad component props type
    D: { Component: (props: boolean) => null },
    // Unexpected 'unknownProp'
    E: { Component: () => null, unknownProp: 123 },
    // Bad 'reservedProp'
    F: { Component: (props: { reservedProp: number }) => null }
};

function configParser<
    Keys extends string,
    ComponentPropsMap extends {
        [Key in Keys]: ReservedComponentProps & Record<string, any>;
    }
>(config: {
    [Key in Keys]: {
        Component: (props?: ComponentPropsMap[Keys]) => React.ReactNode;
    } & AdditionalConfigProps;
}) {
    /*
        TODO: Transform config.
        For now we want to make sure that TS is even able to 'see' it correctly.
    */
    return config;
}

/*
    ❌ Throws unexpected type error
*/
const result = configParser(CORRECT_CONFIG);

// Expected typeof result (what I'd want)
type ExpectedResultType = {
    A: {
        Component: (props: { type: "a"; a: number; b: number }) => null;
        additionalConfigProp: "abc";
    };
    B: { Component: (props: { type: "b"; a: string; c: number }) => null };
    C: { Component: (props: { reservedComponentProp: "c"; a: string }) => null };
    D: { Component: (props: {}) => null };
    E: { Component: () => null };
};

/*
    ❌ Should throw type errors, but not the ones it does
*/
configParser(BAD_CONFIG);

Por supuesto que podría hacer algo así:

function configParser<
    Config extends {
        [key: string]: {
            Component: (componentProps: any) => React.ReactNode;
        };
    }
>(config: Config) {
    return config;
}

// No type error, result type as expected
const result = configParser(CORRECT_CONFIG);

pero eso:

  • no validaría componentProps (tal vez componentProps: Record<string, any> & ReservedComponentProps sí lo haría, pero por alguna razón no aceptaba CORRECT_CONFIG)
  • permitiría cualquier propiedad de configuración adicional

Respuesta #1

Aquí hay un enfoque posible:

type VerifyConfigElement<T extends AdditionalConfigProps &
{ Component: (props: any) => void }> =
  { [K in Exclude<keyof T, "Component" | keyof AdditionalConfigProps>]: never } &
  {
    Component: (
      props: Parameters<T["Component"]>[0] extends ComponentProps ? any : ComponentProps
    ) => void
  }

declare function transformConfig<
  T extends Record<keyof T, AdditionalConfigProps & { Component: (props: any) => void }>>(
    config: T & { [K in keyof T]: VerifyConfigElement<T[K]> }
  ): { [K in keyof T]: (...args: Parameters<T[K]["Component"]>) => void }

La idea es:

  • hacer que transformConfig() sea genérico en el tipo T del parámetro config;
  • restringir T a un tipo relativamente fácil de escribir que no rechace buenos valores de entrada, en este caso es AdditionalConfigProps & {Component: (props: any) => void}>;
  • verifica cada propiedad del T inferido más a fondo, mapeándola de sí misma T[K] a un tipo relacionado VerifyConfigElement<T[K]> donde T[K] extiende VerifyConfigElement<T[K]> si y solo si es una buena entrada;
  • calcula el tipo de retorno a partir de T, mapeando cada propiedad de T en un tipo de función cuyos parámetros se determinan indexando en la propiedad Component correspondiente.

El tipo VerifyConfigElement<T> verifica dos cosas:

  • que T no tiene ninguna propiedad que no se mencione explícitamente en AdditionalConfigProps (o "Component", por supuesto)... esto se logra asignando cualquier propiedad adicional a un tipo never, lo cual casi con certeza fallará en la verificación de tipos;
  • ese primer parámetro del método Component de T se puede asignar a ComponentProps... lo hace mapeando a any si es así (lo cual tendrá éxito) y a ComponentProps si no (lo cual probablemente falle? los tipos de funciones son contravariantes en sus parámetros de entrada, así que podría haber algunos casos límite aquí).

Vamos a probarlo:

const config = {
  A: { Component: (props: { type: "a"; a: number; b: number }) => <div>abc</div> },
  B: { Component: (props: { type: "b"; a: string; c: number }) => <div>abc</div> },
  C: { Component: () => <div>abc</div> }
};
// typeof transformedConfig
type ResultType = {
  A: (props: { type: "a"; a: number; b: number }) => void;
  B: (props: { type: "b"; a: string; c: number }) => void;
  C: () => void;
};////
const transformedConfig: ResultType = transformConfig(config);

¡Se ve bien! Y para tu CORRECT_CONFIG y BAD_CONFIG el compilador los acepta y los rechaza, respectivamente:

const okay = transformConfig(CORRECT_CONFIG); // okay
const bad = transformConfig(BAD_CONFIG); // error

Como se desea.

Enlace de Playground al código