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
Component
property, and all other properties fromAdditionalConfigProps
with correct types, - it won't accept any additional properties on top of defined
Component
and the ones inAdditionalConfigProps
, Component
function 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> & ReservedComponentProps
would, 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 typeT
of theconfig
parameter; - constrain
T
to 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
T
more 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 ofT
into a function type whose parameters are determined by indexing into the correspondingComponent
property.
The VerifyConfigElement<T>
type checks two things:
- that
T
does not have any properties not explicitly mentioned inAdditionalConfigProps
(or"Component"
, of course)... it does this by mapping any such extra properties to have anever
type, which will almost certainly fail to type check; - that
T
'sComponent
method's first parameter is assignable toComponentProps
... it does this by mapping toany
if so (which will succeed) andComponentProps
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.