简体   繁体   中英

How can I narrow a generic class on construction in TypeScript?

I've got a class method, let's call it continue() , that takes a callback. The method returns the same type of value that the given callback returns. So far, simple:

function continue<T>(callback: () => T): T {
   // ...
}

Now, I'm trying to wrap this in a class that is, itself, parameterized over the eventual result of this continue() function — we're gonna call that type Semantic . My class method, continue() , is going to pass along that result to the callback:

class Checkpoint<Semantic> {
   function continue<T>(callback: (val: Semantic) => T): T {
      // ...
   }
}

Now, Semantic can belong to a limited set of possible types; and I need to differentiate behaviour at runtime depending on which flavour of Checkpoint is being used:

type runtimeDiscriminator = 'Script' | 'Statement'

class Checkpoint<Semantic> {
   type: runtimeDiscriminator

   constructor(blah: any, type: runtimeDiscriminator) {
      // ...
      this.type = type
   }

   continue<T>(callback: (val: Semantic) => T): T {
      if (this.type === 'Script') { /* ... do things */ }
      else { /* ... do other things */ }
   }
}

Now, my problem lies in trying to raise this information into the type-system: I wanted to use overloading on the constructor and 'user-defined type guards' to ensure that new Checkpoint(..., 'Script') always returns a Checkpoint<Script> ; and to then type the calls to and return-value of a_checkpoint.continue() :

type runtimeDiscriminator = 'Script' | 'Statement'

class Checkpoint<Semantic> {
   type: runtimeDiscriminator

   constructor(blah: any, type: 'Script'): Checkpoint<Script>
   constructor(blah: any, type: 'Statement'): Checkpoint<Statement>
   constructor(blah: any, type: runtimeDiscriminator) {
      // ...
      this.type = type
   }

   producesScript(): this is Checkpoint<Script> {
      return this.type === 'Script'
   }

   producesStatement(): this is Checkpoint<Statement> {
      return this.type === 'Statement'
   }

   continue<T>(callback: (val: Semantic) => T): T {
      // ... now do runtime type-checks, and narrow knowledge of the resultant type from `callback`
   }
}

Unfortunately, I'm getting the following error from the typechecker:

Type annotation cannot appear on a constructor declaration. ts(1093)

I don't understand this restriction, or how I can work around it in this situation: I need runtime information about the type of an opaque value; but I don't want to annotate every callsite twice, ie new Checkpoint<Statement>('Statement') .

How do I overload constructors like this?


Edit: I hope I didn't mangle any TypeScript terminology here; I'm of OCaml lineage, so sometimes I get a bit swamped in the C++-esque terminology of TypeScript! :x

Note on naming conventions in TypeScript: generic type parameter names are usually just one or two uppercase characters, despite the lack of expressiveness this affords. And type aliases and interfaces are almost always given names with an initial capital letter, while non-constructor values are almost always given names with an initial lowercase letter. I will follow these conventions here.


I think the best way to proceed would be to create a mapping from discriminator RuntimeDiscriminator to discriminated Semantic type, and make your Checkpoint class generic in the discriminator itself. Something like:

interface SemanticMap {
  Script: Script;
  Statement: Statement;
}

type RuntimeDiscriminator = keyof SemanticMap;

Note that there doesn't have to be a single instance of the SemanticMap interface in your code; it's just to help the type system understand the relationship between the string literal name and the type (and interfaces are really well suited for mapping string literal names to types)

class Checkpoint<K extends RuntimeDiscriminator> {
  type: K;

  constructor(blah: any, type: K) {
    this.type = type;
  }

  producesScript(): this is Checkpoint<"Script"> {
    return this.type === "Script";
  }

  producesStatement(): this is Checkpoint<"Statement"> {
    return this.type === "Statement";
  }

Then you can refer to your Semantic type as the lookup type SemanticMap[K] , as in the signature to the continue() method:

  continue<T>(callback: (val: SemanticMap[K]) => T): T {
    return callback(getSemanticInstance(this.type)); // or something
  }

}

( You might find yourself needing to use type assertions or the like in your implementation of continue() , since the compiler generally doesn't like to assign concrete values to generic types; it can't verify those are safe and doesn't really try, see microsoft/TypeScript#24085 . This is true even in your code; it's not specifically a limitation of using SemanticMap[K] instead of Semantic . )

Let's verify that it behaves as you want:

function scriptAcceptor(s: Script): string {
  return "yummy script";
}

function statementAcceptor(s: Statement): string {
  return "mmmm statement";
}

const scriptCheckpoint = new Checkpoint(12345, "Script"); // Checkpoint<"Script">
const scrVal = scriptCheckpoint.continue(scriptAcceptor); // string

const statementCheckpoint = new Checkpoint(67890, "Statement"); // Checkpoint<"Statement">
const staVal = statementCheckpoint.continue(statementAcceptor); // string

const oops = scriptCheckpoint.continue(statementAcceptor); // error!
//                                     ~~~~~~~~~~~~~~~~~
// Argument of type '(s: Statement) => string' is not assignable
// to parameter of type '(val: Script) => string'.

That looks right to me.


As an aside, I think that if you decide to implement the continue() method in such a way as to call those type guards and switch on the result, you might instead consider making Checkpoint<K> an abstract superclass and have concrete ScriptCheckpoint extends Checkpoint<"Script"> and StatementCheckpoint extends Checkpoint<"Statement"> subclasses that each implement their own continue() method. And you'd replace new Checkpoint(blah, "Script") with new ScriptCheckpoint(blah) . This relieves Checkpoint of the burden of having to act like two different things. I don't know your use case though, so I won't go any farther in this direction; it's just something to consider.


Link to code

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