Home > OS >  Typescript Type - append string for each key in object
Typescript Type - append string for each key in object

Time:11-03

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?

CodePudding user response:

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

  • Related