The Problem
protected is an access modifier. You cannot put it on a type or interface, since they are public contracts. So, we won't be able to specify protected properties on a type that's going to be used when defining parameters of a function, including this.
In our case, when we say this: new (ID: string) => T, we are saying the passed argument for this (or technically, the "context object" that this function is called on), should be of type new (ID: string) => T. Which only stands for public properties. :(
As of November 2021 (TS 4.5), there still isn't any way to talk about protected properties of an interface (see this comment), and/or specifying protected constructors of a class.
TS 4.2 has add support for abstract Construct Signatures. So, hopefully in the future we might see some support for the protected constructors, as well.
The Hack
We learned that you cannot enforce that the passed this parameter to have a protected constructor. What's the next best alternative, that's better than any? :)
Let's say we are good with assuming that if it's a subclass of AbstractNanoidGenerator, it's always going to have a protected constructor that takes an (ID: string) and returns an instance of itself. (same subclass.)
So our mission would be just enforcing the this param to be "a subclass of AbstractNanoidGenerator" and properly determine the return type to be an instance of that subtype.
Class vs Instance Object
First of all, let's review the difference between the "Class" and "Instance Object"s.
When you say let id: CompanyID, the type of id is An instance of the CompanyID class.
But the this in the static functions above is not supposed to be this: CompanyID which would imply we need an instance! We want the actual CompanyID class itself to be passed/used, which has the type of typeof CompanyID (see this answer). :)
How should we say we want the class itself?
There are two main ways in TypeScript to have the type for a "Class". That would be used to enforce the this parameter in our case.
Class Type Version 1: Using new
The most common way to imply something is a "Class" is to say if you call new on that, it's gonna produce an "Instance". That inspires (this type of solutions) to go with the following utility functions.
export interface Type<T> extends Function {
new (...args: any[]): T;
}
// --- Or, similarly ---
export type Constructor<VALUE_T = any> = new (...args: any[]) => VALUE_T;
Class Type Version 2: Using prototype
The problem with the above version is that it doesn't work for classes with protected constructor, like our CompanyID. Such classes don't have a public constructor to be picked up by new, and will fail there.
That's why in our second way we rely on having a property called prototype.
export type ClassDefinitionFor<T> = { prototype: T };
(So, let theClass:ClassDefinitionFor<CompanyID> = CompanyID works!)
and to reverse that...
type InstanceOfClass<T> = T extends { prototype: infer R } ? R : never;
What should be the params now?
Given the two utility functions above, I wrote the following.
Note that using as unknown as trick, we are telling TS to consider that just because this is a Class object that extends AbstractNanoidGenerator, it has a constructor that produces an object, with the type on the prototype of that class.
type ClassDefinitionFor<T> = { prototype: T };
type InstanceOfClass<T> = T extends { prototype: infer R } ? R : never;
export abstract class AbstractNanoidGenerator {
static generate<
INSTANCE_T extends AbstractNanoidGenerator,
CLASS_T extends ClassDefinitionFor<INSTANCE_T>,
CTR_T extends new (ID: string) => InstanceOfClass<CLASS_T>,
>(this: CLASS_T): InstanceOfClass<CLASS_T> {
return new (this as unknown as CTR_T)(Math.random().toString());
}
}
(Full TS Playground with extra tests.)