type paramType = {type: string, value: string | number }
const PROFILE: Record<string, paramType> = {
color: { type: 'string', value: 'red' },
height: { type: 'number', value: 180 },
weight: { type: 'number', value: 70 },
}
const constructObj = (selectedFields: string[]) => {
return selectedFields.reduce((accum, cur) => {
return { ...accum, [cur]: PROFILE[cur] };
}, {} as Record<typeof selectedFields[number], paramType>);
}
const result = constructObj(['color']); // { color: {type: 'string', value: 'red' } }
// 1. result.color // Not throwing error, great
// 2. result.xxx // Not throwing error, Not good
constructObj
is a function that will dynamically return an object with keys defined in selectedFields
. I'm facing an issue where result.xxx
is not throwing error?
CodePudding user response:
A slightly different solution from what @ASDFGerte suggested. With this you don't need the as const
when calling the array. Personally, think it looks cleaner.
type paramType = { type: string; value: string | number };
const PROFILE: Record<string, paramType> = {
color: { type: "string", value: "red" },
height: { type: "number", value: 180 },
weight: { type: "number", value: 70 },
};
const constructObj = <T extends string>(
selectedFields: T[]
): Record<T, paramType> => {
return selectedFields.reduce((accum, cur) => {
return { ...accum, [cur]: PROFILE[cur] };
}, {} as Record<typeof selectedFields[number], paramType>);
};
const result = constructObj(["color"]); // { color: {type: 'string', value: 'red' } }
result.color; // Not throwing error, great
result.xxx; // Throwing error now
CodePudding user response:
Addressing @Keith's comment on @nullptr's answer, we can instead use this magic to infer the complete type of selectedFields
without as const
and without the drawback that the result's value
is not string | number
.
However you do have to sacrifice a little inside the body of the function, but hey, a little sacrifice for great typings sounds great to me.
The core of this solution is this interesting definition:
type Narrow<T> =
| (T extends infer U ? U : never)
| Extract<T, number | string | boolean | bigint | symbol | null | undefined | []>
| ([T] extends [[]] ? [] : { [K in keyof T]: Narrow<T[K]> });
Essentially it's a bunch of "tests" to get TypeScript inferring what T
is.
Here is my definition of constructObj
:
const constructObj = <T>(selectedFields: Narrow<T>): T extends string[] ? {
[K in T[number]]: K extends keyof typeof PROFILE_TYPE ? typeof PROFILE_TYPE[K] : never;
} : never => {
return (selectedFields as string[]).reduce((accum, cur) => {
return { ...accum, [cur]: PROFILE[cur] };
}, {} as ReturnType<typeof constructObj<T>>);
}
We use Narrow
to narrow down T
. That means when we call it like this:
constructObj(["color"]);
T
will be ["color"]
, not string[]
! Of course once we have the tuple, we can iterate over the elements with K in T[number]
, and that's our result done.
You'll notice that there is PROFILE_TYPE
now instead of PROFILE
. What's that?
I changed PROFILE
to this:
const PROFILE_TYPE = {
color: { type: 'string', value: 'red' },
height: { type: 'number', value: 180 },
weight: { type: 'number', value: 70 },
};
const PROFILE = PROFILE_TYPE as Record<string, paramType>;
So you'll have your original PROFILE
, but you also have the original that you can use for typings. The best of both worlds.
Still confused about Narrow
?
The way I have used it above is essentially equivalent to the following:
const constructObj = <T>(selectedFields: [T] extends [[]] ? [] : { [K in keyof T]: Extract<T[K], string> }): T extends string[] ? {
Yeah I don't think it's nice to read or maintain either.
But again, what it does, is it tests T
is an empty array with [T] extends [[]]
, then if it isn't, it "loops" over the tuple (because looping over it makes TypeScript infer it as a tuple for some reason) and then uses Extract<T[K], string>
to further get TypeScript to infer each individual string as a literal.