Generic key name for object in callback function

627 Views Asked by At

Can someone help me understand why the "Fails" line fails? And how I can make it work as I would expect, without the workaround casting?

function useObjectWithSingleNamedKey<Key extends string>(
  keyName: Key,
  value: string,
  fn: (obj: {[key in Key]: string}) => void)
{
  fn({ [keyName]: value }) // <-- Fails, but why?
  fn({ [keyName]: value } as { [key in Key]: string }) // <-- Unwanted workaround
}

useObjectWithSingleNamedKey('foobar', 'test', obj => {
  console.log(obj.foobar)  // <-- Should work and does
  console.log(obj.notHere) // <-- Should fail and does
});

Playground Link

I'm getting this error:

Argument of type '{ [x: string]: string; }' is not assignable
to parameter of type '{ [key in Key]: string; }'.(2345)

I want to understand why this currently fails, and how to make it not fail.

How can I type this function so that it type-wise still works "externally" as it does now (i.e. 'foobar' passed in as keyName is available on obj.foober, while obj.notHere or anything else is not), but I'm allowed to call the fn function without using an as cast.


Note: The javascript code works fine, it's the Typescript type check that fails and that I don't understand why it does.

1

There are 1 best solutions below

0
On BEST ANSWER

The reason why it doesn't work is that it is technically possible for the generic <Key> to be broader than just the literal string which is passed as keyName.

obj: {[key in Key]: string} means that obj must have a value for every possible key assignable to Key. You are assuming that this is only one key, but typescript doesn't know that for certain.

Here as an example to illustrate where the function fails. This function call is perfectly valid and has no typescript errors. The failures are inside the body of the function.

useObjectWithSingleNamedKey<'foo' | 'bar'>('foo', 'test', obj => {obj.bar});

If we say that Key is the union of 'foo' | 'bar', then the type of our object obj here is presumed to be {foo: string; bar: string;}. When the function creates an object from the key and value arguments, that object of type {foo: string} won't be sufficient for the expected callback.

Currently there is not way to say that Key can only be a single literal string.

You can use your existing workaround and assume that you won't pass any nonsensical arguments like my example.

Or you can rethink the function. It seems like you have a callback which acts upon objects with a specific key, and you want to be able to call that callback for a particular value directly. Do you need to pass key and value separately? Can you call your callback directly with ({ [keyName]: value })?

You can map specific callbacks to accept a value argument easily:

const barCallback = (obj: {bar: string}): string => obj.bar.toUpperCase();

const callBarCallback = (bar: string): string => barCallback({bar});

But we cannot generalize this mapping because we cannot be sure that the object has only one property.