Eve
Eve

Reputation: 33

Can typescript handle dynamic return type NOT based on a function param?

I know how to handle dynamic type checking using a param type but I'm stuck with this one. It may be not possible, but I can't think straight anymore so any help is welcome !

Given the code:

class DefaultClass {
    defaultProp: number;
    constructor(num: number) {
        this.defaultProp = num;
    }
}

class MyClass extends DefaultClass {
    myProp ?: string;
    constructor(num: number, str ?: string) {
        super(num);
        this.myProp = str || "default value";
    }
    myMethod(str: string): string[] {
        return [str];
    }
}

interface Config {
    class: typeof DefaultClass;
}

const config: Config = {
    class: DefaultClass
}

function initConfig(classType: typeof DefaultClass) {
    config.class = classType;
}

// we don't care for the param type, it's irrelevant here
function myFunction(param: any): InstanceType<typeof config.class> {
    // does something based on which class is used in config
    // returns a DefaultClass or MyClass instance based on the current config
    return new config.class(1);
}

initConfig(MyClass);
const myInstance: MyClass = myFunction("something");
// ERR !: Property 'myMethod' is missing in type 'DefaultClass'
// but required in type 'MyClass'.

As you can guess the static type checking can't dynamically change the return type based on changes done on the config object as it is not "known" before the run. Yet I would like to find a way (if any) to do it.

Upvotes: 0

Views: 1201

Answers (1)

jcalz
jcalz

Reputation: 330216

The big problems with supporting what you're doing directly in TypeScript is that it requires:

  • the type of variables to mutate over time, and
  • these effects need to be visible across function boundaries.

No arbitrary type mutation

TypeScript doesn't support for arbitrarily changing the type of an expression or variable. There's no way to do something like let foo = {a: "hello"}; foo = {b: 123}; and have the compiler change the apparent type of foo from {a: string} to {b: number}. You can annotate the type of foo like let foo: {a?: string; b?: number}, but then the compiler won't notice that anything has changed from one assignment to the next.

All it does have is narrowing via control flow analysis. So if you have a value of some known type, it's possible via assignments or other tools like user-defined type guards or assertion functions to make a variable's apparent type more specific. And so you could conceivably make the compiler notice that config has been narrowed from type {class: typeof Defaultclass} to {class: typeof MyClass}. But:

No type narrowing effects across function boundaries

It doesn't help you because you want changes to the type of config to be visible to callers of myFunction(). And this cannot happen because myFunction() will only see changes in the type of config that occur directly inside the body of myFunction(). Control flow narrowing does not persist across function boundaries, because it would be impossible to implement a general solution that both works and does not seriously degrade compiler performance. There is a GitHub issue, microsoft/TypeScript#9998, that talks about the problems with trying to deal with narrowing effects when functions are called.

So you're kind of stuck.


Instead of doing it this way, my recommendation is: if you want TypeScript to help you, do things it understands. The best thing to do with a variable is to just keep it the same type for its entire lifetime. The implication for your code is to never modify config by calling initConfig(), and instead use the parameters to initConfig to create myFunction() instead. This will have to be packaged as a sort of factory that spits out an implementation. We can use class for that:

class Impl<T extends typeof DefaultClass = typeof DefaultClass> {
    class: T
    constructor(classType: T = DefaultClass as T) {
        this.class = classType;
    }
    myFunction(param: any) {
        return new this.class(1) as InstanceType<T>;
    }
}

const CurImpl = new Impl(MyClass);
const myInstance: MyClass = CurImpl.myFunction("something");

const DifferentImpl = new Impl();
const differentInstance: DefaultClass = DifferentImpl.myFunction("else");

Here CurImpl knows about MyClass because it is an instance of Impl<typeof MyClass>, and its type never changes. If you want to use a different class constructor you have to make a new instance of Impl<T> for some T.


In the above, as an aside, I used a generic parameter default so that if T can't be inferred it will use typeof DefaultClass. And if you don't pass in a classType parameter, the compiler will use DefaultClass. This is technically not type safe, because the compiler can't be sure that T will actually be typeof DefaultClass if you don't pass in the parameter. Someone could call new Impl<typeof MyClass>() and break things. Assuming that nobody would do such a thing, I used a type assertion to just tell the compiler that if classType is not passed in, then the DefaultClass value is assignable to type T. There might be more type-safe ways to do this (possibly without a class) but I don't want to go too far afield of the question as asked.

Playground link to code

Upvotes: 1

Related Questions