ken
ken

Reputation: 9013

Accepting T or Array<T> not working even with Union type

I have a TypedMapper utility class that takes either:

From a flow standpoint it looks like this:

flow diagram

And to enable these dual structures as input/output I have the following as a map() function:

public map() {

  return Array.isArray(this._inputData)
    ? this._inputData.map((item: T) => this.convert(item)) as T[]
    : this.convert(this._inputData) as T;
}

The TypedMapper class works fine but when I use it I want it the map() function to return a discrete type T or T[] not the union of these two types. For example, in the following unit test we get successful JS results but the type is not known to be T or T[]:

const config = {
  passThroughs: ['foo', 'bar', 'baz'],
  defaults: {
    foo: 12345
  }
};
const data = {
  bar: 'hello world'
};
interface IFooBarBaz {
  foo: number;
  bar: string; 
  baz: string;
}
const mapped = new TypedMapper<IFooBarBaz>(data, config).map();
expect(mapped.foo).to.equal(12345);
expect(mapped.bar).to.equal('hello world');
expect(Object.keys(mapped)).to.include('baz');
expect(mapped.baz).to.equal(undefined);

as the following screenshot illustrates:

ts error

Can anyone help me to understand how to ensure that -- based on the input structure being an Array or not -- that the output data structure is known discretely?

Upvotes: 2

Views: 131

Answers (3)

artem
artem

Reputation: 51629

As far as I know, type inference does not cross function boundaries.

You could express what you want with overloads, but for that map() must have data as parameter, and then you can declare two overloads for map, one taking object and returning object, another one taking array and returning array.

It's hard to give precise answer because you don't give definition for TypedMapper in your question, but something like this could work:

class TypedMapper<T, D> {
    constructor(public config: { passThroughs: string[], defaults: Partial<T> }) {
    }
    public map(d: D): T;
    public map(d: D[]): T[];
    public map(d: D | D[]): T| T[] {
         return Array.isArray(d)
                ? d.map((item: D) => this.convert(item)) as T[]
                : this.convert(d);
    }
    public convert(data: D): T {
        return undefined;
    }
}

const config = {
  passThroughs: ['foo', 'bar', 'baz'],
  defaults: { foo: 12345 }
};
const data = {
  bar: 'hello world'
};

interface IFooBarBaz {
  foo: number;
  bar: string; 
  baz: string;
}

const mapped = new TypedMapper<IFooBarBaz, typeof data>(config).map(data);
mapped.foo === 12345;
mapped.bar === 'hello world';

const a = [data, data];

const mappedArray = new TypedMapper<IFooBarBaz, typeof data>(config).map(a);
mappedArray[0].foo === 12345;

Upvotes: 1

jcalz
jcalz

Reputation: 328302

This is a very interesting issue. It would be great if TypeScript performed type inference on optional generic types; then I think we'd be able to expand TypedMapper<T> to TypedMapper<T,D extends T | T[]> and allow D to be inferred from the constructor. Then the output of map() would be D and you'd be all done.

But it doesn't.

What you can do is something like this: create two subclasses (or sub interfaces) of TypedMapper<T> like so:

interface ArrayTypedMapper<T> extends TypedMapper<T> {
  map(): T[]
}
interface NonArrayTypedMapper<T> extends TypedMapper<T> {
  map(): T
}

and create a static method that you use instead of the constructor:

class TypedMapper<T> {
...
  static make<T>(inputData: Partial<T>[], config: any) : ArrayTypedMapper<T>
  static make<T>(inputData: Partial<T>, config: any): NonArrayTypedMapper<T>
  static make<T>(inputData: Partial<T>[] | Partial<T>, config: any): TypedMapper<T> {
    return new TypedMapper<T>(inputData, config); 
  }

Now the overloaded make function will recognize if the inputData is an array or not and return a narrowed TypeMapper<T> type:

const mapped = TypedMapper.make<IFooBarBaz>(data, config).map();
// mapped: IFooBarBaz
const mappedArr = TypedMapper.make<IFooBarBaz>([data], config).map();
// mappedArr: IFooBarBaz[]

This is about as close as I can get. There may be other ways to do it, but all of the ways I could actually get working involve specialized subclasses of TypedMapper<T>.

Hope that helps. Good luck!

Upvotes: 3

Andrew Shepherd
Andrew Shepherd

Reputation: 45252

I'll give you some (probably unsatisfactory) choices:

The first choice is to explicitly cast that mapped value:

expect((<IFooBarBaz>mapped).bar).to.equal('hello world');

A more interesting choice is to wrap it in a type guard.

   if(!Array.isArray(mapped)) {
        expect(mapped.bar).to.equal('hello world');  // No explicit casting!
        expect(mapped.baz).to.equal(undefined);
   } else {
        // Fail the test here!
   }

In this case, the typescript compiler is clever enough to apply logic: since the code within the if block is only run if mapped is not an array, mapped has to be of type IFooBarBaz


I don't know if this counts as 'the output structure is known discretely'.

Upvotes: 0

Related Questions