When you make the resulting function generic, a relationship between the two arguments can be established based on the type 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);
// Not valid
foo('name', 3);
TypeScript Playground
This method works well when the type of your first argument is already narrowed to a literal type, allowing TypeScript to infer the generic K
and subsequently T[K]
.
However, calling it with a union as the generic type, either explicitly or through inference, might pose challenges:
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 }>
// Valid
foo<'age' | 'name'>('age', 'string');
const bar = Math.random() > 0.5 ? 'age' : 'name';
// Also valid
foo(bar, 3);
TypeScript Playground
If this poses an issue for you, there's an alternative solution that restricts only string literal types. Although more complex, it enforces the restriction using utility types:
It utilizes an Equals
type and a UnionToIntersection
type to ensure only literal types are allowed. This approach may not work seamlessly with certain types like Record<string, boolean>
, but covers many scenarios.
Here's how this implementation could be implemented:
/**
* @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 }>;
// Invalid
foo<'age' | 'name'>('age', 'string');
foo('age', 'string');
// Valid
foo('age', 3);
foo('name', 'string');
const uncertainKey = Math.random() > 0.5 ? 'age' : 'name';
// Invalid
foo(uncertainKey, 3);
declare const bar: UpdateFieldValue<Record<string, boolean>>;
// Still not allowed, but perhaps should be
bar(uncertainKey, true);
// Valid
bar('foo', true);
TypeScript Playground
An alternative approach that overcomes some limitations of the generics solution involves linking the arguments' types by defining them under a single type. This is done using spread syntax along with a tuple type.
The tuple type can be created as a discriminated union by utilizing an immediately indexed mapped type, which allows for narrowing both arguments' types within the function by narrowing one of them.
Although the downside includes less friendly intellisense and unclear error messages when invalid arguments are passed, this method provides better results in terms of validity checking:
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 }>
// Invalid
foo<'age' | 'name'>('age', 'string');
foo('age', 'string');
// Valid
foo('age', 3);
foo('name', 'string');
const uncertainKey = Math.random() > 0.5 ? 'age' : 'name';
// Invalid
foo(uncertainKey, 3);
declare const bar: UpdateFieldValue<Record<string, boolean>>;
// Allowed
bar(uncertainKey, true);
// Valid
bar('foo', true);
TypeScript Playground