Member name parameter type restriction
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
. TypePromise<string>
is not assignable to typePromise<string> & Promise<number>
. TypePromise<string>
is not assignable to typePromise<number>
. Typestring
is not assignable to typenumber
.
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:
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 typethis[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 toPromise<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.