Home > OS >  How to pass generic callback as a concrete argument?
How to pass generic callback as a concrete argument?

Time:05-28

TS Playground of the problem

function callStringFunction(callback: (s: string) => void) {
  callback("unknown string inputted by user");
}

function callNumberFunction(callback: (n: number) => void) {
  callback(4); // unknown number inputted by user
}

function genericFunction<T extends string | number>(value: T, genericCallback: (newValue: T) => void) {
  if (typeof value === "string") {
    console.log(value); // T is known to be a string here
    callStringFunction(genericCallback); // But this throws an error: (newValue: T) => void not assignable to (s: string) => void
  }
  else {
    console.log(value); // T is known to be a number here
    callNumberFunction(genericCallback); // But this throws an error: (newValue: T) => void not assignable to (n: number) => void
  }
}

How can I do this without resorting to: callNumberFunction(cb as (n: number) => void)

Since the type for value has been checked, shouldn't TypeScript be aware of the callback type as well?

CodePudding user response:

Unfortunately the TypeScript compiler is not able to understand what you are doing here. When you check typeof value === "string", the compiler treats this as a type guard that narrows value from T to either string or number. But it does nothing to narrow the type parameter T itself.

You would like the compiler to see typeof value === "string" and value: T together and narrow T from T extends string | number to T extends string or T extends number or perhaps just specific string and number type. This does not happen. T stays T throughout the function, and so callStringFunction(genericCallback) cannot be accepted. The compiler still thinks that T might be some arbitrary subtype of string | number.

There are open feature requests in GitHub to improve this situation somehow; see microsoft/TypeScript#33014 for example. Nothing has been implemented yet, though, partly because some "obvious" implementations would lead quickly to unsoundness.

For example, if I had function f<T extends string | number>(value1: T, value2: T): void;, I couldn't say that value1 being a string implies that value2 is also a string. After all, maybe value1 and value2 are both string | number, as in the call f(Math.random()<0.99 ? "" : 123, 123);. There'd be a 99% chance of value1 being a string while value2 is a number. So any code to deal with this would need to be carefully considered.


Furthermore, I can't think of a good way to refactor your code so that the compiler can see what you're doing as safe. The types string and number are not considered discriminant property types, so I can't rewrite the args list to genericFunction() as a discriminated union. That would only work if you passed in a literal parameter like function genericFunction<K extends "str" | "num">(type: K, val: Val[K], cb: (x: Val[K])=>void): void;. In such a case you could check type and then the compiler would narrow val and cb together. But this isn't what you have, so it's not a straight refactoring.

For now I'd say that the best way to proceed is to use a type assertion to just tell the compiler that you know what you're doing. It's reasonable to write val as Type in cases where you are positive that val really is of type Type but the compiler cannot:

function genericFunction<T extends string | number>(
  value: T, genericCallback: (newValue: T) => void
) {
  if (typeof value === "string") {
    console.log(value); // T is known to be a string here
    callStringFunction(genericCallback as (newValue: string) => void);
  }
  else {
    console.log(value); // T is known to be a number here
    callNumberFunction(genericCallback as (newValue: number) => void);
  }
}

This fixes the errors, but places the burden of ensuring type safety on you. It's not great, but it's the best I can figure out how to do as of now, without refactoring to an observably different algorithm. Oh well!

Playground link to code

  • Related