If you want something that works from both the caller's point of view and from the implementer's point of view, you can do this:
function shouldOnlyAcceptStringValues<K extends PropertyKey>(
o: Record<K, string>, key: K
) {
const okay: string = o[key];
}
This is sort of looking at your constraint backwards; instead of constraining key
to be the right keys from obj
, you are constraining obj
to be an object whose value type at key
is a string
. You can see that okay
is accepted as a string
, and things work from the caller's side also:
shouldOnlyAcceptStringValues(obj, "a"); // error!
// ------------------------> ~~~
// Argument of type '{ a: number; b: string; c: string; }' is
// not assignable to parameter of type 'Record<"a", string>'.
shouldOnlyAcceptStringValues(obj, "b"); // okay
shouldOnlyAcceptStringValues(obj, "c"); // okay
The only snag is that the error on the first call is probably not on the argument you expect; it's complaining about obj
and not "a"
. If that's okay, great. If not, then you could change the call signature to be the sort of constraint you're talking about:
type KeysMatching<T, V> = { [K in keyof T]: T[K] extends V ? K : never }[keyof T]
function shouldOnlyAcceptStringValues2<T>(o: T, key: KeysMatching<T, string>): void;
function shouldOnlyAcceptStringValues2<K extends PropertyKey>(
o: Record<K, string>, key: K
) {
const okay: string = o[key];
}
The KeysMatching<T, V>
type function takes a type T
and returns just those keys whose values are assignable to V
. And so the call signature will specify T
for o
and KeysMatching<T, string>
for key
. Note how I've written that call signature as a single overload and the implementation signature is the same as before. If you don't do that then the compiler is unable to understand that for generic T
that T[KeysMatching<T, string>]
is assignable to string
; it's a higher-order type inference the compiler can't make:
function shouldOnlyAcceptStringValuesOops<T>(o: T, key: KeysMatching<T, string>) {
const oops: string = o[key]; // error!
// -> ~~~~
// Type 'T[{ [K in keyof T]: T[K] extends string ? K : never; }[keyof T]]'
// is not assignable to type 'string'.
}
See microsoft/TypeScript#30728 for more information.
So in the overloaded version we let the caller see the constraint on key
and the implementation see the constraint on obj
, which works out better for everyone:
shouldOnlyAcceptStringValues2(obj, "a"); // error!
// ------------------------------> ~~~
// Argument of type '"a"' is not assignable to parameter of type '"b" | "c"'
shouldOnlyAcceptStringValues2(obj, "b"); // okay
shouldOnlyAcceptStringValues2(obj, "c"); // okay
Now the compiler complains about key
instead of obj
.
Okay, hope that helps; good luck!
Playground link to code