1

Given the following object:

const testFunctions = {
   test: () => 'test',
   test2: ( yo: string ) => console.log('test2')
}

and this addCommands function that maps the same functions but appends the component name.

const addCommands = <N extends string, T extends Record<string, any>>(name: N, myFunctions: Readonly<T>) => {
   const commands = {};
   
   Object.keys(myFunctions).forEach((key) => {
      const name = `${name}_${key}`;
      commands[name] = myFunctions[key];
   }

   // Cypress.Commands.addAll(commands); // ignore this
   return commands
}

when I call the addCommands function:

const mappedCommands = addCommands('testing', testFunctions );

the returned type should be of type:

/*
{
   testing_test: () => string;
   testing_test2: (yo: string) => void
}
*/

Any ideas?

Ewan
  • 378
  • 3
  • 14
  • 1
    Does [this approach](https://tsplay.dev/wOAjRm) meet your needs? Note I had to fix some errors in your implementation to get it to work, and I didn't bother trying to get type safety in the function implementation (I just use `any` inside the function so there are no compiler errors)... but the return value and type are what you want. If that fully addresses the question I can write up an answer explaining; if not, what am I missing? (Pls mention @jcalz in your reply to notify me) – jcalz Nov 02 '22 at 16:45
  • Hi, yes this works. I had been close but I didn't know about the `in string` I had errors to do with key not being a string. Thank you – Ewan Nov 02 '22 at 17:09

1 Answers1

2

In what follows I'm only concerned with the call signature of addCommands(), not its implementation. That call signature should look like:

declare const addCommands: <N extends string, T extends Record<string, any>>(
  name: N, myFunctions: T
) => { [K in (string & keyof T) as `${N}_${K}`]: T[K]; }

This is using key remapping in mapped types via as to convert each string-valued key K in the keys of T to a version prepended with the type of name and an underscore. Since name is of type N, the new key type is the template literal type `${N}_${K}`.

Note that if you just write {[K in keyof T as `${N}_${K}`]: T[K]} you get an error that K can't appear in a template literal type. That's because, in general, the keys you get from the keyof operator are some subtype of PropertyKey, and these include symbol-valued keys, which TypeScript doesn't want to let you serialize (after all, an actual template literal value would produce a runtime error if you tried to do that).

To prevent that error we can restrict the keys over which K ranges. One way is to intersect keyof T with string, like (string & keyof T), to get just the string-valued keys. You could write ((string | number) & keyof T) if you want to support number keys (so that {0: ""} gets mapped to {test_0} instead of {}). Or Exclude<keyof T, symbol> using the Exclude<T, U> utility type, et cetera. The point is to convince the compiler that you're not going to try to serialize any symbols.


Let's test it:

const testFunctions = {
  test: () => 'test',
  test2: (yo: string) => console.log('test2')
}

const mappedCommands = addCommands('testing', testFunctions);
/* const mappedCommands: {
    testing_test: () => string;
    testing_test2: (yo: string) => void;
} */

Looks good, that's the type you wanted.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360