Home > front end >  Class property that infers from optional constructor parameter
Class property that infers from optional constructor parameter

Time:06-16

I have following scenario:

class Dispatcher<R extends (...args: any) => any, T = undefined> {
    fn: R
    extra: T
    constructor(fn: R, extra?: T) {
        this.fn = fn;
        this.extra = extra;
    }
}

const test = new Dispatcher(() => {
    return true;
});

// TS type hinting should know that extra is undefined
test.extra;

const test2 = new Dispatcher(() => {
    return true;
}, {
    graphUrl: "/foo/"
});

// TS type hinting should know that extra exists and has a property graphUrl
test2.extra.graphUrl

In this case I want the extra class member to infer based on whether it was passed to the constructor. If I pass a boolean, it should be a boolean, if I pass a string, it should be a string, if I pass undefined, it should be undefined. Right now the inference works and extra properly types, but TS throws an error in the constructor code when I assign this.extra = extra. Strictly speaking that error makes sense because I'm assigning a type that can be undefined to a type that cannot. The problem is if I update the member definition to be extra?: T then the inference breaks because now it thinks extra is something like string | undefined, which is incorrect. The problem is the extra variable isn't actually "optional" it is the value that it was passed, that value may be undefined... that's different than optional.

Type 'T | undefined' is not assignable to type 'T'.
  'T' could be instantiated with an arbitrary type which could be unrelated to 'T | undefined'.

If I fix the error in the constructor code, then the type inference stops working and I get something like string | undefined which isn't accurate. We know the type because it was passed to the constructor.

Playground link

CodePudding user response:

Unfortunately, generic types are not always inferred. Sometimes they are explicit. So typescript is guarding you against a case like:

new Dispatcher<() => boolean, string>(() => true)

Now according to the type of your class, this is a valid way to instantiate the class. But internally, extra will be assigned undefined when it should be a string.


I'm not entirely sure how to fix this without a type assertion, but you can at least make it safe to do the type assertion.

What we need to do is make the argument optional if T is undefined. And you can do that by making the function arguments a conditional type that resolves to a tuple.

class Dispatcher<R extends (...args: any) => any, T = undefined> {
    fn: R
    extra: T
    constructor(...args: T extends undefined ? [fn: R] : [fn: R, extra: T]) {
        const [fn, extra] = args
        this.fn = fn;
        this.extra = extra as T; // as T is still needed, sadly.
    }
}

Now if T is undefined, then the argument is missing from the arguments tuple. Else, extra is required and will need to be of type T.

The above case that broke without error, is now an error:

new Dispatcher<() => boolean, string>(() => true) // Expected 2 arguments, but got 1.(2554)

Here because T is string, then the second argument is required.

But these still work as expected:

new Dispatcher<() => boolean>(() => true) // fine
new Dispatcher<() => boolean, undefined>(() => true) // fine
new Dispatcher<() => boolean, string>(() => true, 'foobar') // fine

Typescript is still confused that extra is safe to assign, and I'm not really sure how to fix that. But at least now I believe it's impossible to get an unsupported type into extra, so the as T should be safe.

Playground

There may still be a better solution, though.

  • Related