By making the resulting function also generic, you can set up a relationship between your two arguments based on T
:
type UpdateFieldValue<T extends Record<string, unknown>> = <K extends keyof T>(key: K, value: T[K]) => void
declare const foo: UpdateFieldValue<{ age: number, name: string }>
// Works
foo('age', 3);
// No good
foo('name', 3);
TypeScript Playground
This works quite well in cases where the type of your first argument, which is what TypeScript uses to infer the generic type K
and through it T[K]
, is already narrowed to a literal type.
However, if you try to call it with the generic type set to a union, either explicitly or through inference, then you may run into trouble:
type UpdateFieldValue<T extends Record<string, unknown>> = <K extends keyof T>(key: K, value: T[K]) => void
declare const foo: UpdateFieldValue<{ age: number, name: string }>
// Works
foo<'age' | 'name'>('age', 'string');
const bar = Math.random() > 0.5 ? 'age' : 'name';
// Also works
foo(bar, 3);
TypeScript Playground
If this is a problem for you, I think I've found to force only string literal types to be allowed. But it requires use of a few complex utility types to accomplish this. Also, for certain types (e.g. Record<string, boolean>
) it may not be preferred behaviour anyway. But I'd like to include it for completeness.
It uses an Equals
type and a UnionToIntersection
type, to convert the argument's type to an intersection of its union members, then checks that it has not changed. This should only be the case for literal types, which can be considered unions with 1 meember, and for never
(effectively a union with 0 members).
Here's how that implementation might look:
/**
* @see {@link https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650}
*/
type Equals<A, B> = (
<T>() => T extends A ? 1 : 2
) extends (
<T>() => T extends B ? 1 : 2
) ? true : false;
/**
* @see {@link https://stackoverflow.com/a/50375286/1710523}
*/
type UnionToIntersection<U> = (
U extends unknown ? (arg: U) => void : never
) extends (arg: infer I) => void ? I : never;
type LiteralOnly<S> = Equals<S, UnionToIntersection<S>> extends true ? S : never;
type UpdateFieldValue<T extends { [key: string]: unknown; }> = <K extends keyof T>(key: LiteralOnly<K>, value: T[K]) => void;
declare const foo: UpdateFieldValue<{ age: number, name: string }>;
// No good
foo<'age' | 'name'>('age', 'string');
foo('age', 'string');
// Works
foo('age', 3);
foo('name', 'string');
const uncertainKey = Math.random() > 0.5 ? 'age' : 'name';
// No good
foo(uncertainKey, 3);
declare const bar: UpdateFieldValue<Record<string, boolean>>;
// Still not allowed, but perhaps should be
bar(uncertainKey, true);
// Works
bar('foo', true);
TypeScript Playground
A different approach, which doesn't have the drawbacks discussed above in the generics solution, is to link the types of your arguments by defining them as a single type. This is possible using spread syntax and a tuple type.
That tuple type can be constructed as a discriminated union by using an immediately indexed mapped type, which should allow you to narrow both arguments' types inside your function by narrowing either of them.
The downside to this approach is that the intellisense is not so nice, and it's not as clear what's gone wrong when invalid arguments are passed. But if all you care about is whether something is allowed, this approach does seem to give better results:
type UpdateFieldValue<T extends Record<string, unknown>> = (
...[key, value]: { [K in keyof T]: [K, T[K]] }[keyof T]
) => void
declare const foo: UpdateFieldValue<{ age: number, name: string }>
// No good
foo<'age' | 'name'>('age', 'string');
foo('age', 'string');
// Works
foo('age', 3);
foo('name', 'string');
const uncertainKey = Math.random() > 0.5 ? 'age' : 'name';
// No good
foo(uncertainKey, 3);
declare const bar: UpdateFieldValue<Record<string, boolean>>;
// Allowed
bar(uncertainKey, true);
// Works
bar('foo', true);
TypeScript Playground