Home > Back-end >  typescript generic function returning same type as parameter
typescript generic function returning same type as parameter

Time:10-08

Trying to write a function that can take a single value or array as the parameter, handle it appropriately and return the same type.

Tried this:

myFunc = <T extends string | string[]>(parm1: T): T =>
{
  if (Array.isArray(parm1)) {
    return parm1.map(p => p);
  } else {
    // Simplest thing is just to recurse with the value in an array.
    return this.myFunc([parm1])[0];
    };
}

Logically, it should work, right? If parm1 is an array, I always return a string array. If it's a string, I always return a string. But I get this:

Type 'string[]' is not assignable to type 'T'. 'string[]' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | string[]'.ts(2322)

I can force it like so:

myFunc = <T extends string | string[]>(parm1: T): T =>
{
  if (Array.isArray(parm1)) {
    return parm1.map(p => p) as T;
  } else {
    // Simplest thing is just to recurse with the value in an array.
    return this.myFunc([parm1])[0] as T;
    };
}

But brute-forcing like this feels like cheating. How can I do this without the heavy-handed assertion? Is there really something for typescript to be complaining about here? Or is this a corner case that typescript just doesn't understand?

CodePudding user response:

Why about moving the generic type into the union type instead, i.e. T being able to be either T or T[] instead? The first step is to do something like this:

const myFunc: IOverload = <T extends string>(parm1: T | T[]): T | T[] =>
{
  if (Array.isArray(parm1)) {
    return parm1.map(p => p);
  } else {
    // Simplest thing is just to recurse with the value in an array.
    return myFunc([parm1])[0];
    };
}

myFunc(['1','2','3']);
myFunc('1');

But notice that the typing of both myFunc(['1','2','3']) and myFunc(1) will return the same union type. We can easily solve this with overloads:

type MyFuncOverload = {
    <T extends string>(parm1: T): T,
    <T extends string>(parm1: T[]): T[],
}

const myFunc: MyFuncOverload = (parm1) =>
{
  if (Array.isArray(parm1)) {
    return parm1.map(p => p);
  } else {
    // Simplest thing is just to recurse with the value in an array.
    return myFunc([parm1])[0];
    };
}

myFunc(['1','2','3']);  // Inferred type is `string[]`
myFunc('1');            // Inferred type is `string`

See example on TypeScript playground.

CodePudding user response:

This seems like a constrained version of an identity function:

function identity <T>(value: T): T {
  return value;
}

You can use an overloaded function signature to match the type of input:

TS Playground

function constrainedIdentity <T extends string>(value: T): T;
function constrainedIdentity <T extends string[]>(value: T): T;
function constrainedIdentity <T extends string | string[]>(value: T) {
  return value;
}

const value1 = constrainedIdentity(['foo', 'bar']);
    //^? const value1: string[]
const value2 = constrainedIdentity('foo');
    //^? const value2: "foo"

This pattern gives you the flexibility to adjust the return type for each call signature, in the case that you decide to transform the resulting value in some way, for example:

TS Playground

function transform <T extends string>(value: T): number;
function transform <T extends string[]>(value: T): number[];
function transform <T extends string | string[]>(value: T) {
  return Array.isArray(value)
    ? value.map(Number)
    : Number(value);
}

const value1 = transform(['foo', 'bar']);
    //^? const value1: number[]
const value2 = transform('foo');
    //^? const value2: number


On (the problems with) the code shown in the question:

Ignoring the string possibility for a moment...

In the case of an array input, if your actual goal is to map it (and that wasn't just a contrived bit of implementation code in your example), here's one reason why the implementation that you showed doesn't match the return type:

TS Playground

function myFunc <T extends string[]>(parm1: T): T {
  return parm1.map(p => p); /*
  ~~~~~~~~~~~~~~~~~~~~~~~~~
  Type 'string[]' is not assignable to type 'T'.
  'string[]' is assignable to the constraint of type 'T', but
  'T' could be instantiated with a different subtype of constraint 'string[]'.(2322) */
}

// Here's an array with a special extra property:
const abc = Object.assign(['a', 'b', 'c'], { specialProp: 'special value' });
   //^? const abc: string[] & { specialProp: string }

console.log(abc.specialProp); // "special value"

// The original type is proxied by the function:
const fnResult = myFunc(abc);
   //^? const fnResult: string[] & { specialProp: string }

// However, that's not actually true:
console.log(fnResult.specialProp); // undefined

// The real type is:
const mapResult = abc.map(str => str);
   //^? const mapResult: string[]

CodePudding user response:

Your code isn't type correct, because a caller could write the following:

class A extends Array<string> {
  foo() {
    console.log("Hello from foo");
  }
}

const a = myFunc(new A());
a.foo();

This compiles just fine, because myFunc promises to return an instance of the same type it is passed, which has a member foo, but fails at runtime, because myfunc lied and returned an object of a different prototype, which doesn't have a foo.

That is, the key point you're missing is that type parameters are specified by the caller, and K extends string | string[] really means that K extends string or string[], so K can be string, string[], or any subtype thereof. But if it is a subtype, your implementation of myFunc doesn't return an instance of that type. And that's why the compiler says:

Type 'string[]' is not assignable to type 'T'. 'string[]' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | string[]'.(2322)

(emphasis mine)

It's not complaining that string[] might not be string[], but that T might have been instantiated with a subtype of string[], and string[] may not be assignable to that subtype.

The correct typing for your function is an overload type:

  myFunc(s: string) : string;
  myFunc(a: string[]): string[];
  myFunc(parm1: string | string[]) {
    if (Array.isArray(parm1)) {
        return parm1.map(p => p);
    } else {
        // Simplest thing is just to recurse with the value in an array.
        return this.myFunc([parm1])[0];
    };
  }

Now, the parameter is either string or string[], and we promise to return a string or a string[], respectively. We make no special guarantees with respect to subtypes of string or string[], so if a subtype of string[] is passed, myFunc will treat this like any string[], and return a simple string[], not an instance of that special array type.

  • Related