2

I would like to understand how to get where I want with generic type so this is my example :

function foo(value: 42 = 42 ,bar:(val: any) => any = I => I){
return bar(value)
}

I work with a value of some type T in my example the type is a number so I am using 42 I want to have a function as an argument that will change the value to a different type R or return a same type T which in this case would be the identity 'I => I' as the default parameter value.

I rewrote my function as an arrow function like this:

const foo = <R1>(
  value: number = 42,
  bar: <R0>(val: number) => R0 = I => I
) => {
  return bar<R1>(value);
};

const foo0 = (
  value: number = 42,
  bar: <R0>(val: number) => R0 = I => I
) => {
  return bar(value);
};

const foo1 = <R1>(
  value: number = 42,
  bar: (val: number) => R1 = I => I
) => {
  return bar(value);
};


ERROR: Type 'number' is not assignable to type 'RX'. 'RX' could be instantiated with an arbitrary type which could be unrelated to 'number'.

I am using R1 and R0 because I am not sure the difference between R one and R naught in this example but if I remove the I => I default value the error message go away but I don't understand why or how to overcome it...

in the below example fooB(42,i=>i) and foo0B(42,i=>i) erored out but not foo1B(42,i=>i)

How can I set the default value I => I without having to specify both R | number

const fooB = <R1>(value: number = 42, bar: <R0>(val: number) => R0) => {
  return bar<R1>(value);
};

const foo0B = (value: number = 42, bar: <R0>(val: number) => R0) => {
  return bar(value);
};

const foo1B = <R1>(value: number = 42, bar: (val: number) => R1) => {
  return bar(value);
};


fooB(42,i=>i)
foo0B(42,i=>i)
foo1B(42,i=>i)

Benjamin Vincent
  • 486
  • 6
  • 17

1 Answers1

2

The error message that you get from your examples is caused by the fact that type arguments can be explicitly given when calling a function, for example:

fooB<number>(42, (i) => i);

considering fooB:

const fooB = <R1>(value: number = 42, bar: <R0>(val: number) => R0) => {
    return bar<R1>(value);
};

what happens here is that internally bar can be called with any other type besides R1 too. If that is the case there is no guarantee that number (the type of value) is a type that's compatible with R0.

The problem arises when we'd do for example:

fooB<21>(42, (i) => i);

About this call we can say: R1 is instantiated with/as 21.

Here 42 extends number as it's supposed to considering the type of value, but number extends 21 is FALSE. In other words: 42 and 21 are different subtypes of type number. Therefore the returned i is not assingable to R1 nor R0. A similar problem occurs with foo0B and R0.

There's already some good coverage on why this error message occurs.


The fix

If I understand correctly, you want a function that:

  • Takes an argument of type T
  • Optionally takes a function fn that takes an argument of type T and returns a value of type U
  • Calls fn with the first argument, and then returns the result (U) if fn is given, or returns T otherwise (the identity).

The most straightforward way to write this is like the following:

function foo<T, U>(value: T, fn: (val: T) => T | U = i => i): T | U {
    return fn(value);
}

But here both foo and fn can indeed only return the union type T | U, since there's only one signature to account for both the cases

  • Without fn
  • With fn

If you want branching of types depending on whether a callback is passed (I'm assuming this based on the default I => I) you can use function overloading instead. Essentially it's like saying "I have the following 2 signatures for foo":

function foo<T>(value: T): T;
function foo<T, U>(value: T, fn: (val: T) => U): U;
function foo<T>(value: T, fn = (i: T) => i) {
    return fn(value);
}

The first function signature is for when no callback is passed: it takes a T, and will just return a T. In that sense foo is like the identity function.

The second signature takes the callback fn. If no type arguments are explicitly given to a call to foo, U is going to be inferred from the type of fn. Otherwise the type of fn will be validated against the given type for U.

Then you have the function implementation. It can account for both signatures, and does so using the identity function (default value) to match the first signature, or a given function to match the second. This implementation signature only explicitly specifies the types that are constant for all overloads, so only T is specified. The overloads will do the rest by determining the return type on a per-call basis.

Here's a complete TS playground using the following examples:

// Without callback
const a: number = foo(42);             // Fine
const b: string = foo("I'm a string"); // Fine
const c: string = foo(42);             // ERROR: type '42' is not assignable to type 'string'

// With callback
const d: number = foo('42', parseInt);                  // Ok
const e: string = foo(42, (x: number) => x.toString()); // Ok
const f: string = foo(42, (x: number) => x + 1);        // ERROR: type 'number' is not assignable to type 'string'

// With explicit types
const g: number = foo<string, number>('42', parseInt);  // Ok
const h: number = foo<string, number>('42', (x) => x);  // ERROR: type 'string' is not assignable to type 'number'
JJWesterkamp
  • 7,559
  • 1
  • 22
  • 28