1

Take this example:

type UpdateFieldValue<T extends Record<string, unknown>> = (key: keyof T, value: SomeType) => void

I want SomeType to be the value type of the property (key) of T, key being provided by the first function parameter.

So if T looked like:

{ age: number, name: string }

Then in the case of:

UpdateFieldValue<{ age: number, name: string }>('name'...

TS should require a string to be passed as the second parameter, and in the case of:

UpdateFieldValue<{ age: number, name: string }>('age'...

TS would require a number to be given as the second parameter

Every time I get something working it seems to fail as soon as I need to pass T explicitly as a type param. Thanks.

Darryl Edwards
  • 119
  • 1
  • 10
  • Does [this](https://www.typescriptlang.org/play?#code/C4TwDgpgBAqmAmBDYEBiBLCAbeA1RWArhADwAqUEAHigHbwDOUAShAMYD2ATvCQ8F3S0A5gBoohWgGtaHAO60AfIqgBeKAAoAUFF1QAdIYDaUiCHEA3AsQC6ALigBvKEYDSUIVFMgOAMyhk9i6u4mRuNjZQAL4mZn4BNloAlGoqFhzo8Fpa8OxYiFzQnLT8UL4cHA5wSCgY2HjWpM6IwhAOtIQAtgBGEFzitIidbVD8giLRitkA9NNQAOrcUgxa5RwaAOQtEBviAMxJANwzcwByHFDCFVlrm4PDu1AHx0A) do what you need? If so, I can write up an answer when I have time. If not, what am I missing? – Mark Hanna Jul 22 '23 at 12:12
  • 1
    Actually perhaps [this](https://www.typescriptlang.org/play?#code/C4TwDgpgBAqmAmBDYEBiBLCAbeA1RWArhADwAqUEAHigHbwDOUAShAMYD2ATvCQ8F3S0A5gBoohWgGtaHAO60AfIqgBeKCQDSlGhHpMpEEBwBmUMooAUhkAC4om8QDcCxe2QDamgLoBKNSpOHOjwAFCh8OxYiFzQnLT8UCYcHPZwSCgY2HiupADeUIjCEPa0hAC2AEYQXOK0iOUlUPyCIlAAvorhAPTdUADq3FIMockclgDkRRAT4gDMvgDcPX0AchxQwilhY5P1jbNQC8tAA) is a nicer approach – Mark Hanna Jul 22 '23 at 12:16
  • @MarkHanna that looks to work perfectly, thanks so much! TIL! If you add the answer I will mark it as accepted. Thanks again! – Darryl Edwards Jul 22 '23 at 12:30

1 Answers1

1

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

Mark Hanna
  • 3,206
  • 1
  • 17
  • 25