Search code examples
typescripttypeguardstype-mapping

How to map type and add a this-based type guards in TypeScript?


Suppose there is a interface of arbitrary members like this:

interface A extends Record<string, any[]>{
  // Later used by interface merging
}

//maybe
declare module "./main"{
  interface A{
    func: [arg1:string]
  }
}

And I want to map A like this, with this-based type guards:

interface Try1<T extends keyof A>{
  [K in keyof A]: (...args: A[K]) => this is Try1<K> 
  // Lots of errors
}
type Try2<T extends keyof A> = {
  [K in keyof A]: (...args: A[K]) => this is Try1<K> 
  // Error: A 'this' type is available only in a non-static member of a class or interface.ts(2526)
}

So how can I do it?

Actual usage:

interface Elements extends Record<string, [any[], any]>{
  // Later used by interface merging
}

interface Elements{
  button: [[text: string], MouseEvent];
}

class ImguiContext<CurrentEv extends keyof Elements>{
  // !!!
  [E in keyof Elements]: (...args: Elements[E][0]) => this is ImguiContext<E>;

  event: Elements[CurrentEv][1];
}


function main(_: ImguiContext){
  if(_.button("Click me")){
    // now `_.event` should be `MouseEvent`
    console.log("clicked at X:", _.event.screenX);
  }
}

Another possible solution, but failed

let a:string|number = 1;
function aIsNumber(/*no parameter*/)/*: ???*/ {
  return typeof a === "number";
}

??? can be

  1. Assertion functions like asserts condition
  2. Type predicates like a is number

but both failed


Solution

  • It's apparently a missing feature of TypeScript to allow "this" type predicates (as originally implemented in microsoft/TypeScript#5906) in mapped types. If you try to do it you'll get the error you mentioned. There's an open feature request at microsoft/TypeScript#51958 to support this, but for now it's not officially part of the language. The issue is listed as "Awaiting more feedback", so if you want to see this happen it couldn't hurt to go there, give it a đź‘Ť, and describe why your use case is compelling.

    Interestingly, in that issue it is noted in that issue that—despite the error—the code actually does behave as expected.

    So one could possibly issue a //@ts-ignore comment directive to suppress the error, and treat this as an "unsupported feature". Here's how it might look for your example:

    interface Elements {
      button: [[text: string], MouseEvent];
      other: [[age: number, isSquishy: boolean], Date]; 
    }
    
    type ImguiContext<K extends keyof Elements = keyof Elements> = {
      //@ts-ignore this is usually a bad idea
      [E in keyof Elements]: (...args: Elements[E][0]) => this is ImguiContext<E>;
    } & { event: Elements[K][1]; }
        
    function main(_: ImguiContext) {
      if (_.button("Click me")) {
        // now `_.event` should be `MouseEvent`
        console.log("clicked at X:", _.event.screenX); // okay
      } else if (_.other(2, false)) {
        console.log("otherThing", _.event.getFullYear()); // okay
      }
    }
    

    I really wouldn't recommend doing that, though. The //@ts-ignore directive only prevents the error from being displayed; it doesn't fix the underlying problem the compiler has. Maybe that's fine because the underlying problem is confined to the error reported, or maybe you'll run into weird indirect issues in other parts of your code because of it. The documentation for the directive says it should be used "very sparingly" (emphasis theirs). So use it at your own risk.


    Instead I'd be inclined to wrap your context in a version whose type TypeScript can represent without errors. If, instead of being a mapped type, ImguiContext had a test() method that accepted the name and args, you could rewrite it as a plain interface:

    interface ImprovedContext<T = unknown> {
      test<E extends keyof Elems>(
        name: E, ...args: Elems[E][0]
      ): this is ImprovedContext<Elems[E][1]> // no ts-ignore needed
      event: T
    }
    

    If you need to write a helper function to wrap your old context in the new one you can, since that only requires a type assertion which is less dangerous than //@ts-ignore:

    function improveContext(_: ImguiContext): ImprovedContext {
      return {
        test(n, ...args) { return (_[n] as any)(...args) },
        event: _.event
      }
    }
    

    And then things still work:

    function main2(_Orig: ImguiContext) {
      const _ = improveContext(_Orig);
      if (_.test("button", "Click me")) {
        console.log("clicked at X:", _.event.screenX); // okay
      } else if (_.test("other", 2, false)) {
        console.log("otherThing", _.event.getFullYear()); // okay
      }
    }
    

    Of course it depends on your use case whether it's more feasible to rely on an unsupported feature or to refactor to a version that is supportable.

    Playground link to code