I have a TypeScript function useSome that uses generics to define a params object based on a given key or set of keys:
const useSome = <Keys extends string | undefined>() => {
const params = {} as Keys extends string ? Record<Keys, string> : Partial<Record<string, string>>;
return params;
};
Here’s how I want to use this function:
- Single Key Example:
const { id } = useSome(); // Expected: id is of type string | undefined
- Multiple Keys Example:
const { id, type } = useSome<'id' | 'type'>(); // Expected: id and type are both of type string
However, when I provide multiple keys in the generic, I get the following error:
Property 'id' does not exist on type 'Record<"id", string> | Record<"type", string>'.
Here’s a TypeScript’s Playground link to demonstrate the issue.
My Questions:
How can I modify the useSome function so that it works correctly with multiple keys?
Is it possible to achieve the desired behavior when passing multiple keys in the generic type?
2
Caveat: It’s not clear to me why you’d want useSome<'id'>()
to produce a value of type {id: string}
and not {id?: string}
, since at runtime you’re just calling useSome()
and there’s no possible way to guarantee that the keys a TS developer specifies will actually exist. It’s effectively like using a type assertion, but hidden inside the implementation of your function. If you’re going to do that, I’d recommend just having people write useSome() as {id: string}
and be explicit about the lack of type safety. From here on out, though, I’ll assume you have a reason why useSome()
should produce {[k: string]: string | undefined}
but useSome<'id'>
should produce {id: string}
.
My inclination would be to define your types like this:
const useSome = <K extends string>() => {
const params = {} as string extends K ? { [P in K]?: string } : { [P in K]: string }
return params;
}
If you call useSome()
without manually specifying the generic type argument for K
, TypeScript will fail to infer it, and it will fall back to the constraint of string
. On the other hand if you call useSome()
and specify a string literal type or a union of such types, like useSome<'x'|'y'>()
, then K
will be narrower than string
.
Thus we can just check if K
is or is not narrower than string
. We know that K extends string
, but we need the opposite check. The conditional type string extends K ? ⋯ : ⋯
will be true if K
is exactly string
(or wider, but it can’t be wider by the constraint) and false if K
is some string literal type or union of such types.
So if string extends K
is true then we want optional properties as in {[P in K]?: string}
, which evaluates to {[k: string]: string | undefined}
(since a mapped type over string
produces a string index signature and the ?
mapping modifier just adds undefined
to the index signature property, since there are no optional index signatures). Otherwise we want required properties as in {[P in K]: string}
.
Let’s check it out:
const { id, type } = useSome<'id' | 'type'>()
// ^? const id: string
/* const useSome: <"id" | "type">() => {
id: string;
type: string;
} */
const { id2 } = useSome()
// ^? const id2: string | undefined
/* const useSome: <string>() => {
[x: string]: string | undefined;
}*/
Looks good.
Playground link to code
If I understand your question correctly, you want the useSome
function to return an object with properties based on the provided generic type arguments and to correctly handle multiple generic keys.
Try this:
const useSome = <Keys extends string | undefined>(...keys: Keys[]): Record<Extract<Keys, string>, string> => {
const params = {} as Record<Extract<Keys, string>, string>;
// Example of how you might populate `params` with some values.
keys.forEach(key => {
params[key] = "some value";
});
return params;
}
// Usage examples
const { id } = useSome('id'); // type of id is string
const { id, type } = useSome('id', 'type'); // type of id is string, type of type is string
Hope it helps!
1