Adam D
Adam D

Reputation: 2257

Generics for corresponding sub-types of union types in TypeScript

Let's say I have a union type that I am using with a reducer-like pattern for API calls that looks something like this:

type Action = {
    request: {
        action: "create user",
        payload: { name: string, age: number },
    },
    response: { ok: true, message: "user created" },
} | {
    request: {
        action: "delete user",
        payload: { id: number },
    },
    response: { ok: true, message: "user deleted" },
};

When I choose a particular action, I want to be able to predict what the corresponding response will be with type safety. For example, if I were to make a function like this, I want to enforce that I am indeed returning the proper corresponding response for every request. I am not having a trouble with type safety on the input types. I am having trouble linking the output to the corresponding sub-type related to the input type.

function doAction<T extends Action>(request: T["request"]): T["response"] {
    if (request.action === "create user") {
        const userName = payload.user.name; // Here we're good for type safety 👍
        return {
            ok: true,
            message: "user deleted", // This should error, but it doesn't 👎
        };
    } else {
        const { id } = request.payload; // Again, we're good here for type safety 👍
        return {
            ok: true,
            message: "user deleted", // Ok, but missing strong type safety here 👎 
        }; 
    }
}

How can I indicate that I want the ["response"] part of the object type chosen in the ["request"], and not just any Action["response"]?

TypeScript Playground Link

I would also like to be able to link these two corresponding halves of the object when making middleware functions like this, for example:

function<T extends Action>(req: Request<T["request"]>, res: Response<T["response"]>) {
    if (req.body.action === "create user") {
        res.send({ ok: true, message: "user deleted" }); // this should not be allowed
    }
    ...

Upvotes: 4

Views: 316

Answers (1)

In order to reduce complexity and increase type safety it worth using here strategy pattern.

Consider this example:

type Action = {
    request: {
        action: "create user",
        payload: { name: string, age: number },
    },
    response: { ok: true, message: "user created" },
} | {
    request: {
        action: "delete user",
        payload: { id: number },
    },
    response: { ok: true, message: "user deleted" },
};

type Strategy = {
    [Prop in Action['request']['action']]: Extract<Action, { request: { action: Prop } }>['response']
}

const strategy: Strategy = {
    'create user': { ok: true, message: "user created" },
    'delete user': { ok: true, message: "user deleted" }
};

function doAction<T extends Action['request']>(request: T): Extract<Action, { request: T }>['response']
function doAction(request: Action['request']) {
    return strategy[request.action]

}

// {
//     ok: true;
//     message: "user created";
// }
const createUser = doAction({
    action: "create user",
    payload: { name: 'string', age: 42 },
})


// {
//     ok: true;
//     message: "user deleted";
// }
const deleteUser = doAction({
    action: "delete user",
    payload: { id: 1 },
})

Playground

Strategy - creates hash map data structure where key is an action name and value is a return type of your function.

Also, I have overloaded doAction function in order to narrow return type.

Upvotes: 1

Related Questions