Home > Net >  TypeScript generics, constraint for function paramters in object
TypeScript generics, constraint for function paramters in object

Time:08-02

What I want to accomplish:

Let's say that there is a config with type like this:

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 };
};

so, generally speaking something of shape like this:

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;
};

I'd like to transform a config like this, but:

  • preserve hard types for keys ('A' | 'B' | 'C' instead of string)
  • preserve hard types for props ({ type: "a"; a: number; b: number } instead of Record<string, any>)
  • make sure that transform function only accepts correct configs, that is:
    • it has Component property, and all other properties from AdditionalConfigProps with correct types,
    • it won't accept any additional properties on top of defined Component and the ones in AdditionalConfigProps,
    • Component function has to be able to accept ComponentProps-like object as first argument,

Transformation may look like this:


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;
};

Please notice that:

  • Hard types for keys 'A' | 'B' | 'C' were preserved
  • Hard types for 'props' were preserved

Approach I've tried:

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);

Of course I could do something like this:

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);

but it:

  • wouldn't validate componentProps (maybe componentProps: Record<string, any> & ReservedComponentProps would, but for some reason it wouldn't accept CORRECT_CONFIG)
  • would allow any additional config properties

CodePudding user response:

Here's one possible approach:

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 }

The idea is to:

  • make transformConfig() generic in the type T of the config parameter;
  • constrain T to a relatively easy-to-write type that doesn't reject good inputs, in this case it's AdditionalConfigProps & {Component: (props: any) => void}>;
  • check each property of the inferred T more thoroughly, by mapping it from itself T[K] to a related type VerifyConfigElement<T[K]> where T[K] extends VerifyConfigElement<T[K]> if and only if it's a good input;
  • compute the return type from T, by mapping each property of T into a function type whose parameters are determined by indexing into the corresponding Component property.

The VerifyConfigElement<T> type checks two things:

  • that T does not have any properties not explicitly mentioned in AdditionalConfigProps (or "Component", of course)... it does this by mapping any such extra properties to have a never type, which will almost certainly fail to type check;
  • that T's Component method's first parameter is assignable to ComponentProps... it does this by mapping to any if so (which will succeed) and ComponentProps if not (which will probably fail? function types are contravariant in their input parameters, so there might be some edge cases here).

Let's test it:

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);

Looks good! And for your CORRECT_CONFIG and BAD_CONFIG the compiler accepts and rejects them, respectively:

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

As desired.

Playground link to code

  • Related