2

I try to create a class to abstract the map function of Arrays to works transparently on single values.

export class Container<T> {
  value: T;

  constructor(value: T) {
    this.value = value;
  }

  map<U>(f: (x: T extends Array<infer R> ? R : T) => U): Container<T extends Array<any> ? Array<U> : U> {
    if (Array.isArray(this.value)) {
      const mapped = this.value.map(f);
      return new Container(mapped); // <-- Error A here
    }

    return new Container<U>(f(this.value /* <-- Error B here */)); // <-- Error C here
  }

  unwrap(): T {
    return this.value;
  }
}

//Sample usage
const sayHello = (name: string) => `Hello ${name}`;
const shout = (str: string) => str.toUpperCase();

const a = new Container("Toto").map(sayHello).map(shout).unwrap() // HELLO TOTO
const b = new Container(["Harry", "Ron", "Hermione"]).map(sayHello).map(shout).unwrap() // ["HELLO HARRY", "HELLO RON", "HELLO HERMIONE"]

But typescript complains, and i don't understand why the type doesn't match

// Error A 
Type 'Container<U[]>' is not assignable to type 'Container<T extends any[] ? U[] : U>'.
  Type 'U[]' is not assignable to type 'T extends any[] ? U[] : U'.ts(2322)

// Error B 
Argument of type 'T' is not assignable to parameter of type 'T extends (infer R)[] ? R : T'

// Error C
Type 'Container<U>' is not assignable to type 'Container<T extends any[] ? U[] : U>'.
  Type 'U' is not assignable to type 'T extends any[] ? U[] : U'

I now I can "fix" it with any but look for a cleaner solution

Varkal
  • 1,336
  • 2
  • 16
  • 31
  • 1
    Is `new Container(...)` really cleaner than `[...]`? Why not just wrap things in an array instead of a custom class? – kaya3 Aug 17 '21 at 10:23
  • 2
    Have you found [this question](https://stackoverflow.com/questions/55641731/typescript-conditional-type-complains-type-not-assignable)? I guess it's the same issue, but I'm not 100% sure – A_A Aug 17 '21 at 10:29
  • @A_A Thanks, this explain why my solution doesn't works ! – Varkal Aug 26 '21 at 08:26

1 Answers1

1

Here's a solution which is kind of clunky but works: let T be the individual element type (instead of either the element type or an array type), then have a second type parameter for whether or not value is an array. You still need a type assertion in the unwrap method.

I also had to replace the constructor with a static factory method with two overloads, so that the second type parameter can be provided correctly.

export class Container<T, IsArray extends boolean> {
  value: T | T[];
  
  static of<T>(value: T[]): Container<T, true>;
  static of<T>(value: T): Container<T, false>;
  static of(value: unknown) {
    return new Container(value);
  }
  private constructor(value: T | T[]) {
    this.value = value;
  }

  map<U>(f: (x: T) => U): Container<U, IsArray> {
    if (Array.isArray(this.value)) {
      const mapped = this.value.map(f);
      return new Container(mapped);
    }

    return new Container<U, IsArray>(f(this.value));
  }

  unwrap(): IsArray extends true ? T[] : T {
    return this.value as IsArray extends true ? T[] : T;
  }
}

Playground Link

That said, it would be much simpler if you just used plain arrays. You can wrap a single value in an array like [value] instead of Container.of(value) and there is no more overhead than wrapping it in a Container instance; and then there is no overhead needed when your value is an array. Compare:

const a = ["Toto"].map(sayHello).map(shout)[0];
const b = ["Harry", "Ron", "Hermione"].map(sayHello).map(shout);
kaya3
  • 47,440
  • 4
  • 68
  • 97