Reputation: 852
I've been playing around with lodash and typescript and found the following.
Say you have a user defined type guard that has the following signature:
isCat(animal: Animal) animal is Cat
And you have a list of animals that you'd like to filter:
let animals: Animal[] = // assume some input here
let cats = _.filter(animals, isCat);
Then the type system will actually infer that cats is of type Animal[], not of type Cat[].
However if you extend the lodash typings like this (Sorry, I was using chaining here just by coincidence, but you get the idea):
interface TypeGuardListIterator<T, TResult extends T> {
(value: T, index: number, list: List<T>): value is TResult;
}
interface _Chain<T> {
filter<TResult extends T>(iterator: TypeGuardListIterator<T, TResult>): _Chain<TResult>;
}
Then the type system will actually infer that the cats variable is of type Cat[]. This is awesome! Maybe it should be added to the typings for this library.
Here's the question: Assuming you have multiple types of animals, how could you do this with a group by, and also have the type inference work properly?
let groupedAnimals = _.groupBy(animals, animal => {
if (isCat(animal)) {
return "cats";
} else if (isDog(animal)) {
return "dogs";
} else if (isHorse(animal)) {
return "horses";
}
});
Ideally the type of groupedAnimals would look something like this:
interface GroupedAnimals {
cats: Cat[];
dogs: Dog[];
horses: Horse[];
}
Is this even possible? I feel like this would be trying to aggregate multiple type guards into one function here. Conceptually the types make sense, but I'm not sure how this could be achieved.
Upvotes: 3
Views: 1391
Reputation: 13105
You can't use your type guards for this, but there are other ways to distinguish between types at type level.
To make this possible you will have to augment groupBy with a signature that "understands" that you are trying to distinguish between union members through the returned value at the type level.
The common term for this is union discrimination and the most common approach to discriminate between union members is through a static tag member that can be used to discriminate in the type level as well as during runtime. This post elaborates more on the concept of tagged unions & union discrimination.
Omitting the Horse
type for brevity, following is what that would like in your case:
import {groupBy} from "lodash";
interface Cat {
_type: "cat"
}
interface Dog {
_type: "dog"
}
type Animal = Cat | Dog;
const animals: Animal[] = [];
declare module "lodash" {
interface LoDashStatic {
groupBy<T extends Animal>(collection: List<T>, iteratee?: (i: T) => T["_type"]): {
[K in T["_type"]]: Array<T & {_type: K}>
};
}
}
// Use groupBy
const group = groupBy(animals, (animal) => animal._type);
If the above code does not make sense you may need to read more about mapped types and module augmentation.
The inferred type of group will be:
const group: {
cat: ((Cat & {
_type: "cat";
}) | (Dog & {
_type: "cat";
}))[];
dog: ((Cat & {
_type: "dog";
}) | (Dog & {
_type: "dog";
}))[];
}
which is effectively what you want (because Dog & {_type: "cat"}
and Cat & {_type: "dog"}
will never match anything), but looks ugly.
To clean it up a bit, you can use a discriminator interface:
interface AnimalDiscriminator {
cat: Cat,
dog: Dog
}
which you can map over in your groupBy
signature:
declare module "lodash" {
interface LoDashStatic {
groupBy<T extends Animal>(collection: List<T>, iteratee?: (i: T) => T["_type"]): {
[K in T["_type"]]: Array<AnimalDiscriminator[K]>
};
}
}
Now the type of group will be:
const group: {
cat: Cat[];
dog: Dog[];
}
which looks much nicer.
Upvotes: 1