简体   繁体   中英

Typescript: how do I most explicitly define a variable that can hold a function name or that function's return type?

I'm refactoring/rebuilding an existing codebase, and came across the equivalent of this code:


class FeedbackBase {
    Variables = [{Value: 'doSomething'}];

    goToFeedback() {

        this.Variables?.forEach(variable => {
            if (variable.Value) {
                variable.Value = (this[variable.Value])();
            }
        });
    }

    doSomething(): string { return '123';}
}

The basic idea is that Variables[].Value might contain a function call defined on FeedbackBase . If it does, then run the function, and replace Variables[].Value with the return value.

Before this.Variables?.forEach , they were originally doing a deep copy of this.Variables , wiping out all of the typing and basically running the code on an 'any' object. When we keep the typing, Typescript complains about this[variable.Value] :

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'FeedbackBase'. No index signature with a parameter of type 'string' was found on type 'FeedbackBase'.

Fair enough: all Typescript knows is that the contents of variable.Value are a string : it doesn't know whether it is a callable function on FeedbackBase .

My potential solution:

type BaseFunction = {
    doSomething(): string;
};

class FeedbackBase implements BaseFunction {
    Variables = [{Value: 'doSomething'}];

    goToFeedback() {
        this.Variables?.forEach(variable => {
            if (variable.Value) {
                variable.Value = this[variable.Value as keyof BaseFunction]();
            }
        })
    }

    doSomething(): string { return '123';}
}

Basically:

  1. Within BaseFunction , explicitly define all of the functions that could be defined in variable.Value
  2. FeedbackBase implements BaseFunction
  3. Call the function as this[variable.Value as keyof BaseFunction] to tell Typescript that this should be treated as a function call, not as a string.

A nice feature of this approach is that when you say this[variable.Value as keyof BaseFunction] , Typescript checks to make sure that this indeed has all of the functions defined in BaseFunction . (For example, if you change FeedbackBase.doSomething to FeedbackBase.doSomethings , an error appears at this[variable.Value as keyof BaseFunction] ).

I think that, in this case, I'm forced to use as , because variable.Value could be keyof BaseFunction , or whatever is returned by the function call. Even if I get more specific about the return type, I can't think of a way to define variable.Value as definitely a function call before the transformation, and definitely a return type afterward, outside of just using as .

Is there a way to be more explicit?

I don't know if this is the best TypeScript per say because it seems a bit convoluted, but I think your solution is fine in terms of typings. I would beware of one thing though.

I don't think the forEach does what you want it to. If a Value is not a function, you'll get a runtime error because you're not actually checking if Value is a function. Instead, you're checking if it exists.

this.Variables?.forEach((variable) => {
      if (
        variable.Value &&
        variable.Value in this &&
        typeof this[variable.Value as keyof BaseFunction] === "function"
      ) {
        variable.Value = this[variable.Value as keyof BaseFunction]()
      }
})

TS would not allow you to do that if it knew what you wanted to do, and I think it would be right in complaining. This is what this answer will attempt to demonstrate.

You don't need an interface to wire the types. You can statically work out the names of FeedbackBase non-void method names like so:

type NonVoidMethodNames<Class> = keyof {
    [K in keyof Class
        as ((...args: any[]) => void) extends Class[K] ? never
        : Class[K] extends (...args: any[]) => unknown ? K
        : never
    ]: K
}

type DoSomething = NonVoidMethodNames<FeedbackBase> // "doSomething"

If you assigned it to Variables like the following, you would not need your as when you call this[variable.Value]() .

Variables: { Value: NonVoidMethodNames<FeedbackBase> }[] = [{ Value: 'doSomething' }];

TS would try to index this with "doSomething" , realise it's a method, no problem, and work out the return type as string as expected

But of course we can't simply do that because now we're assigning string to variable.Value which we had defined to be "doSomething" .

playground

In TS, you can't write the following and this is the situation you are in, only with indirections in-between.

let foo: string = 'a';
foo = 1;

You would also be inconvenienced if you wanted to widen Value to MethodName | ReturnType MethodName | ReturnType when the return type is string as it is the case here because string would swallow its subtypes.

Even ignoring the problem of when Variables is populated, the user has no way to know what values this field contains. This is a deal breaker in my opinion. I am not very savvy in OO but I would say this design is flawed and cannot be typed.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM