daaku
daaku

Reputation: 2807

Limiting keys based on value types in TypeScript

I need a sort of setProperty function that allows setting properties on an object, but limiting them to properties of type string.

It seems like I've figured out how to limit the allowed keys to the ones that have a value type of K, but TypeScript doesn't seem to recognize that entirely. Here's what I have:

type KeysMatching<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never;
}[keyof T];

function bindChange<C, K extends KeysMatching<C, string>>(
  container: C,
  key: K,
): (ev: KeyboardEvent) => void {
  return (ev: KeyboardEvent) =>
    // @ts-ignore how do we tell typescript container[key] is a string?
    (container[key] = (ev.target! as HTMLInputElement).value);
}

const sample = {
  foo: 'bar',
  baz: 42,
};

// this is correctly allowed
bindChange(sample, 'foo');

// this is correctly rejected
//bindChange(sample, 'baz');

Playground Version.

What I'm trying to figure out is if there is a better approach that can eliminate the need for the @ts-ignore in there.

Upvotes: 1

Views: 602

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249636

In all fairness, the function is not 100% type safe, there is no way to ensure container[key] is not a subtype of string. The constraints are only a minimal requirement, the actual types can be more specific. Consider this:


const sample = {
  foo: 'bar',
  baz: 42,
} as const

// this is correctly allowed
bindChange(sample, 'foo'); // but 'foo' is of type 'bar' but is assigned a string

The problem is that in general Ts does not really do a lot of reasoning about conditional types that still have unresolved type parameters. So a lot of times generic function implementations will end up with type assertions. Don't worry about it too much IMO.

Since there is no way to make sure all properties are exactly string, the only solution, if you consider this corner case as not important, is to use a type assertion:


function bindChange<C, K extends KeysMatching<C, string>>(
  container: C,
  key: K,
): (ev: KeyboardEvent) => void {
  return (ev: KeyboardEvent) =>
    (container[key] = (ev.target! as HTMLInputElement).value as unknown as C[K]);
}

Playground Link

There is also this version, which does get around the issue, but looses intelisense for the function call, which is more important in my opinion:

type KeysMatching<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never;
}[keyof T];

function bindChange<C extends Record<K, string>, K extends PropertyKey>(
  container: C,
  key: K,
): (ev: KeyboardEvent) => void {
  return (ev: KeyboardEvent) =>
    (container[key] = (ev.target! as HTMLInputElement).value as unknown as C[K]);
}

const sample = {
  foo: 'bar',
  baz: 42,
};

// this is correctly allowed
bindChange(sample, 'foo');

Playground Link

Upvotes: 1

Related Questions