Reputation: 9013
I have a TypedMapper utility class that takes either:
T
T[]
From a flow standpoint it looks like this:
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:
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
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
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
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
Upvotes: 0