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 ofstring) - preserve hard types for props (
{ type: "a"; a: number; b: number }instead ofRecord<string, any>) - make sure that transform function only accepts correct configs, that is:
- it has
Componentproperty, and all other properties fromAdditionalConfigPropswith correct types, - it won't accept any additional properties on top of defined
Componentand the ones inAdditionalConfigProps, Componentfunction has to be able to acceptComponentProps-like object as first argument,
- it has
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(maybecomponentProps: Record<string, any> & ReservedComponentPropswould, but for some reason it wouldn't acceptCORRECT_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 typeTof theconfigparameter; - constrain
Tto a relatively easy-to-write type that doesn't reject good inputs, in this case it'sAdditionalConfigProps & {Component: (props: any) => void}>; - check each property of the inferred
Tmore thoroughly, by mapping it from itselfT[K]to a related typeVerifyConfigElement<T[K]>whereT[K] extends VerifyConfigElement<T[K]>if and only if it's a good input; - compute the return type from
T, by mapping each property ofTinto a function type whose parameters are determined by indexing into the correspondingComponentproperty.
The VerifyConfigElement<T> type checks two things:
- that
Tdoes not have any properties not explicitly mentioned inAdditionalConfigProps(or"Component", of course)... it does this by mapping any such extra properties to have anevertype, which will almost certainly fail to type check; - that
T'sComponentmethod's first parameter is assignable toComponentProps... it does this by mapping toanyif so (which will succeed) andComponentPropsif 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.
