Member name parameter type restriction

November 24th 2023 TypeScript

In my previous blog post I explained how the keyof type operator in TypeScript can sometimes be used as a replacement for parameters passed by reference which aren't supported in JavaScript. Let's try to restrict the parameter to only allow names of members of a specific type.

For the following method to work, the backingField parameter would have to be restricted to members of specific type, Promis<string> | undefined to be exact:

export class DelayedStringFactory {
  public stringPromise1: Promise<string> | undefined;
  public stringPromise2: Promise<string> | undefined;
  public numberPromise: Promise<number> | undefined;

  private getOrCreateStringPromise(
    backingField: keyof DelayedStringFactory,
    delay: number,
    value: string
  ): Promise<string> {
    const promise =
      this[backingField] ??
      new Promise<string>((resolve, _) => {
        setTimeout(() => resolve(value), delay);
      });
    this[backingField] = promise;
    return promise;
  }
}

Since the keyof DelayedStringFactory doesn't enforce such a restriction on the parameter, the compiler reports an error when we try to write a value to the backing field:

Type Promise<string> | Promise<number> is not assignable to type (Promise<string> & Promise<number>) | undefined. Type Promise<string> is not assignable to type Promise<string> & Promise<number>. Type Promise<string> is not assignable to type Promise<number>. Type string is not assignable to type number.

Fortunately, conditional types can be used to specify such a restriction:

type KeyOfType<TOwner, TMember> = keyof {
  [K in keyof TOwner as TOwner[K] extends TMember ? K : never]: any;
};

We have defined a conditional type that describes a member name of TOwner that extends the type TMember. We can now use it for the first parameter of our method:

private getOrCreateStringPromise(
  backingField: KeyOfType<DelayedStringFactory, Promise<string> | undefined>,
  delay: number,
  value: string
): Promise<string> {
  const promise =
    this[backingField] ??
    new Promise<string>((resolve, _) => {
      setTimeout(() => resolve(value), delay);
    });
  this[backingField] = promise;
  return promise;
}

With this change, the compiler allows us to assign the value to the backing field.

When calling the method, the restriction is enforced. The method is allowed to be called with a name of a member of a matching type:

public getOrCreateStringPromise1(
  delay: number,
  value: string
): Promise<string> {
  return this.getOrCreateStringPromise("stringPromise1", delay, value);
}

But the code will fail to compile if we try to call the method with a name of the member with of a different type:

Argument of type "numberPromise" is not assignable to parameter of type "stringPromise1" | "stringPromise2".

As you can see, the error message includes the resulting literal union type instead of the conditional type used in the code. This makes it easier to understand for the developer and also allows the tooling to list the allowed values when calling the method:

Allowed member names as parameter value.png

Unfortunately, this conditional type doesn't seem to work when using this for the owner type instead of the actual type, i.e., DelayedStringFactory. If you try to use this instead, you will get a hard-to-understand error message when trying to assign a value to the backing field, which seems to indicate that the compiler failed to resolve the conditional type:

Type Promise<string> | NonNullable<this[keyof { [K in keyof this as this[K] extends Promise<string> | undefined ? K : never]: any; }]> is not assignable to type this[keyof { [K in keyof this as this[K] extends Promise<string> | undefined ? K : never]: any; }]. this[keyof { [K in keyof this as this[K] extends Promise<string> | undefined ? K : never]: any; }] could be instantiated with an arbitrary type which could be unrelated to Promise<string> | NonNullable<this[keyof { [K in keyof this as this[K] extends Promise<string> | undefined ? K : never]: any; }]>.

Usually, that's not a problem, but it makes it impossible to put the helper method in the base class. If you specify the owner type as the base class, the conditional type will not be aware of class members in the derived class.

You can find full source code in my GitHub repository if you want to try it out or play with it further.

Don't worry if this is the first time you've heard of conditional types in TypeScript. It's not something you need to use often, but it could help with a typing problem that can't be solved otherwise. Even if that's the case, the official documentation does a good job of explaining them. And there's also a high probability that somebody else had a need for a similar conditional type before, which you can use as a basis for your type.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License