Home > Software engineering >  Template literal from keyof in class
Template literal from keyof in class

Time:05-18

I'm trying to combine a bunch of TypeScript features at once.

I created a class which is initialized with an object, and has a bunch of functions which should only be able to take as arguments keys of that object. So far so good. Got some help with that yesterday, and my basic setup looks like this:

class MyClass<Type> {
  properties: Partial<Type> = {};

  constructor(properties: Type) {
    this.properties = properties;
  }
  
  pick(...propNames: Array<keyof Type>) {
    return propNames.reduce((obj, name) => ({ 
      ...obj, 
      [name]: this.properties[name]
    }), {} as Partial<Type>);
  }
}

But I actually want both the keys themselves, and slightly modified versions of those keys to be accepted -- specifically, both "key" and "key!". In my case, if you append an exclamation to the key, that (acceptable) input is used to indicate that the key is required.

I found template literals in the handbook, and saw that I could do something like this:

type Name = 'John';
type BangName = `${Name}!`;

Perfect! However, I have no idea how to integrate this into my use case, in which the first type is generated from keyof, and this integration is occurring inside a JS class.

I saw this question, which gets close. It suggests that I could do something like the below, which generates a combined interface, from which I can derive acceptable keys using keyof:

type BangType = {
  [Key in keyof Type as `${Key}!`]: Type[Key];
}

type CombinedType = Type & BangType;

and then:

pick(...propNames: Array<keyof CombinedType>) {

But I don't know where I can generate this CombinedType, since the generic Type is defined in the class signature. Is this possible?

The below doesn't seem to work:

pick(...propNames: Array<keyof PropType> | Array<Key in keyof PropType as `${Key}!`>)

CodePudding user response:

You can indeed use template literal types here. If you want propNames to be an array of either keyof T or "the strings you get if you append "!" to the end of the strings in keyof T", then the type you're looking for is

Array<keyof T | `${Extract<keyof T, string>}!`>

Note that since T is some generic type you don't control, it's possible for it to have some symbol-valued keys. And since keyof T may have symbols in it, the compiler will be unhappy if you try to just write the template literal type `${keyof T}!` directly, because template literal types can only serialize strings and numbers (uh, and bigints, and booleans, and null and undefined. But that's it):

oops(...propNames: Array<keyof T | `${keyof T}!`>) { } // error!
// ---------------------------------> ~~~~~~~
// 'symbol' is not assignable to 'string | number | bigint | boolean | null | undefined'.

Since we're just interested in the string-valued keys of T, we use the Extract<T, U> utility type to grab just those. And then the compiler does not complain:

pick(...propNames: Array<keyof T | `${Extract<keyof T, string>}!`>) {
  return propNames.map(k =>
    typeof k === "string" ? k.replace(/!$/, "") as keyof T : k
  ).reduce((obj, name) => ({
    ...obj,
    [name]: this.properties[name]
  }), {} as Partial<T>);
}

And let's make sure it works:

const n = new MyClass({ a: 1, b: "two", c: true });    
const x = n.pick("b!");
console.log(x) // {b: "two"}

Looks good!

Playground link to code

  • Related