how to write filter over union type in typescript

Viewed 6965

I have the following example to illustrate what I'm trying to get.

const v1: { type: "S"; payload: string } = { type: "S", payload: "test" };
const v2: { type: "N"; payload: number } = { type: "N", payload: 123 };

type Actions = typeof v1 | typeof v2;

const findByType = <A extends Actions>(type: A["type"]) => (
    action: Actions
): action is A => action.type === type;

const filterWithBothNameAndType = [v1, v2].filter(findByType<typeof v1>("S"));
console.log(filterWithBothNameAndType[0].payload.trim());

const findByTypeDoesntWork = <A extends Actions, T extends A["type"]>(type: T) => (
    action: Actions
): action is A => action.type === type;

const filterWithJustType = [v1, v2].filter(findByTypeDoesntWork("S"));
console.log(filterWithJustType[0].payload.trim());

typescript playground

I have function findByType that has right type information and I have function filterWithJustType that has api that I like, but it loses type information. I want api to be just filter("S") without passing generic type. So far it looks like it will only work with classes and instaceof but I wanted to make it work with plain objects.

3 Answers

You can use Exclude or Extract as mentioned by the docs, for example:

type T00 = Exclude<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "b" | "d"
type T01 = Extract<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "a" | "c"

type T02 = Exclude<string | number | (() => void), Function>;  // string | number
type T03 = Extract<string | number | (() => void), Function>;  // () => void

Thanks to @artem for giving me idea of ActionMap, I added actionCreator so that it ensures that keys and payload types are in sync, and behold:

type ActionMap = {
    S: string;
    N: number;
};

function actionCreatorFactory<
    T extends keyof ActionMap,
    P extends ActionMap[T]
>(type: T) {
    return function actionCreator(payload: P) {
        return { type, payload };
    };
}

const actionCreators = {
    s: actionCreatorFactory("S"),
    n: actionCreatorFactory("N"),
};

const v1 = actionCreators.s("test");
const v2 = actionCreators.n(123);

const findByType = <
    T extends keyof ActionMap,
    A extends { type: T; payload: ActionMap[T] }
>(
    type: T
) => (action: A): action is A => action.type === type;

const filterWithJustType = [v1, v2].filter(findByType("S"));
console.log(filterWithJustType[0].payload.trim());

this solution ensures that ActionMap is the only place where you have to declare types, and everything else will be derived from it.

UPDATE: Article was just published with more examples of this approach https://medium.com/@dhruvrajvanshi/some-tips-on-type-safety-with-redux-98588a85604c

It does not work, because it requires the compiler to make the assumption that if some type that extends Actions has a type member with literal type S, then it must be typeof v1.

This assumption in general is not safe to make, because extends Actions is pretty weak constraint, and this compiles:

const v3: { type: "S"; payload: boolean } = { type: "S", payload: false };

const filterWithJustType3 = [v1, v2, v3].filter(findByTypeDoesntWork("S"));

As an aside, it does not compile in 2.6 with strictFunctionTypes turned on, but type inference is not taking advantage of additional soundness introduced by this option, and it's not clear that it should.

But there is a way to tell explicitly to the compiler that a union member can be unambiguously inferred only from the type of it's type member. You just need to establish type mapping yourself:

const v1: { type: "S"; payload: string } = { type: "S", payload: "test" };
const v2: { type: "N"; payload: number } = { type: "N", payload: 123 };

interface ActionMap {
    S: typeof v1;
    N: typeof v2;
}

type Actions = ActionMap[keyof ActionMap];


const findByTypeWorks = <T extends keyof ActionMap>(type: T) => (
    action: Actions
): action is ActionMap[T] => action.type === type;

const filterWithJustType = [v1, v2].filter(findByTypeWorks("S"));
console.log(filterWithJustType[0].payload.trim());
Related