Home > Blockchain >  Type representing a generic class with a private constructor
Type representing a generic class with a private constructor

Time:10-25

I have a simple function that asserts that an object is an instance of a class. The object and the class are both passed as arguments to the function.

The following is a simplified example that illustrates the issue (without getting into the nitty-gritty of the real code):

function verifyType<T>(instance: unknown, classType:new () => T):instance is T {
    if (!(instance instanceof classType)) throw(`Expecting instance of ${classType.name}`);

    return true;
}

(TypeScript Playground link)

This function works well with most classes:

class Foo {
    constructor() {
    }
}

const foo:unknown = new Foo();
verifyType(foo, Foo);
// OK

However, I get compiler errors if the class has a private constructor.

I understand the reasons for this and it makes sense. A constructor type implies that the calling code could construct the class and a private constructor means this is not permitted.

However, I can't find an alternative that still allows me to use instanceof:

class Bar {
    private constructor() {
    }
    static create() {
        return new Bar();
    }
}
const bar:unknown = Bar.create();
verifyType(bar, Foo);
// OK
verifyType(bar, Bar);
// Argument of type 'typeof Bar' is not assignable to parameter of type 'new () => Bar'.
//  Cannot assign a 'private' constructor type to a 'public' constructor type.

Trying T extends typeof Object

I read How to refer to a class with private constructor in a function with generic type parameters?

Based on the answer to that question, I thought that maybe I could use the following:

function verifyType<T extends typeof Object>(instance: unknown, classType:T):instance is InstanceType<T>

However, this appears to throw errors because of Object's many static methods:

const foo = new Foo();
verifyType(foo, Foo);
// Argument of type 'typeof Foo' is not assignable to parameter of type 'ObjectConstructor'.
//   Type 'typeof Foo' is missing the following properties from type 'ObjectConstructor': getPrototypeOf, getOwnPropertyDescriptor, getOwnPropertyNames, create, and 16 more.

Getting exotic

I tried many variations on typeof Object to see if I could both satisfy TypeScript and also ensure runtime correctness, including:

function verifyType<T extends Function & Pick<typeof Object, "prototype">>(instance: unknown, classType:T):instance is InstanceType<T>

However, while this solved the compile-time errors, it appears to have done so by allowing types that fail at runtime, which is not acceptable:

verifyType(bar, ()=>{});
// No compile-time errors
// Runtime Error: Function has non-object prototype 'undefined' in instanceof check 

Help me // @ts-expect-error, you're my only hope

In the end, this might be too niche a use-case and I might have to accept that this edge-case won't be supported any time soon and exempt my code accordingly.

// @ts-expect-error Unfortunately, TS has no type representing a generic class with a private constructor
verifyType(bar, Bar);

CodePudding user response:

My suggested approach is to do inference on the prototype property of the classType parameter, like this:

function verifyType<T extends object>(
    instance: unknown,
    classType: Function & { prototype: T }
): asserts instance is T {
    if (!classType.prototype) throw ( 
      `Wait, ${classType.name || "that"} is not a class constructor`);
    if (!(instance instanceof classType)) throw (
      `Expecting instance of ${classType.name}`);
}

Generally speaking TypeScript will let you write x instanceof y if y is of some function type, so hence Function &, and then we infer the generic type parameter T corresponding to the instance type of classType by saying that it's the type of the prototype property (TypeScript models class constructor prototypes as being the same type as an instance of the class, even though in practice that is not usually true. See microsoft/TypeScript#44181 and probably others for more information).

In addition to Function & {protoype: T} I've made a few changes from your code:

  • verifyType() is now an assertion function returning asserts instance is T instead of a type guard function returning instance is T. Type guard functions are supposed to return true or false and the compiler uses this in its control flow analysis. Assertion functions on the other hand don't return any value, they just ensure that the narrowable thing is definitely narrowed. If you have a type guard function that always returns true (and throws otherwise) then you probably want an assertion function instead.

  • I've done an explicit check inside the body to see if classType.prototype is defined. TypeScript gives Function a prototype property of the any type instead of the safer unknown type, and this unfortunately makes it almost impossible to detect that a non-constructor like ()=>{} would throw a runtime error if made the target of instanceof. Instead of trying to represent that in the type system, then, I just try to harden verifyType()'s implementation against such edge cases. You might want to check (!classType.prototype || typeof classType.prototype !== "object") or any other logic you feel is necessary.


Let's test it:

class Foo {
    constructor() {
    }
    a = 1;
}
const foo: unknown = new Foo();
foo.a // error! Object is of type unknown
verifyType(foo, Foo);
foo.a // okay
console.log(foo.a.toFixed(1)) // "1.0"

Looks good, the compiler sees that foo is a Foo after (but not before) verifyType(foo, Foo) has been called.

class Bar {
    private constructor() {
    }
    static create() {
        return new Bar();
    }
    b = "z"
}
const bar: unknown = Bar.create();
verifyType(bar, Bar);
console.log(bar.b.toUpperCase()); // "Z"

Also looks good. The compiler is happy to allow verifyType(bar, Bar) because Bar has a prototype property of type Bar (or so it appears to the compiler) and thus you can then access the b property of Bar. It doesn't matter that Bar has a private constructor.

And finally:

const baz: unknown = new Date();
verifyType(baz, () => { }); //            
  • Related