Repository navigation
Narrow discriminated union based on generic function argumentsΒ #46899
Description
Suggestion
π Search Terms
narrowing, generic, arguments
β Viability Checklist
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- This feature would agree with the rest of TypeScript's Design Goals.
β Suggestion
In a few different projects, I've wanted to write a wrapper function that takes as arguments
- A discriminating union
- A literal discriminator
- A callback function which accepts the narrowed type from the union
To be more specific, I wanted to be able to do something like this:
interface Thing {
key: "bar";
property1: number;
}
interface OtherThing {
key: "baz";
property2: string;
}
type Things = Thing | OtherThing;
type NarrowedThing<A, K> = A extends { key: K } ? A : never;
const narrowingFunction = <T extends Things, K extends T["key"]>(
thing: T,
key: K,
callback: (thing: NarrowedThing<T, K>) => void
) => {
if (thing.key === key) {
callback(thing); // Type 'Thing' is not assignable to type 'NarrowedThing<T, K>'.(2345)
}
};
const maybeThing: Things = {
key: "bar",
property1: 2,
};
narrowingFunction(maybeThing, "bar", (narrowedThing: Thing) => {
console.log(narrowedThing.property1);
});Now it may be that my understanding of TypeScript is too limited to understand what is possible here, but it seems to me that it would be correct for thing to be automatically narrowed to something like my NarrowedThings type
π Motivating Example
I recently tried to write a small library to simplify the handling of AWS EventBridge events in a way that allowed you to provide handler functions and cleanly narrowed the types. The API looked a little like this:
const { step, execute } = initPipeline(event);
// Both 'processor' variables below are handler functions that
// take a single event object narrowed to the correct type using the
// string that is passed in argument one as a discriminator
execute(
step('EventOne', eventOneProcessor),
step('EventTwo', eventTwoProcessor),
)Under the hood, this API is just an abstraction over the simple 3 argument function I describe above. In order to narrow the type I had to write a custom type guard as there appeared to be no way for TypeScript to narrow arguments that were typed with generics.
π» Use Cases
I've also wanted something like this when handling redux actions within a reducer - I can see libraries such as ts-pattern making use of this under the hood. As I note above, it is perfectly possible to implement this with a custom type guard, but it seems to me that everything that is needed to safely narrow the type is known at build time so a custom guard shouldn't be necessary.
Activity
RyanCavanaugh commentedon Nov 22, 2021
The example as written is pretty tough; I'm not sure how we could do that.
A possibly tractable (and functionally equivalent version) would be
The relationship between the comparison and the narrowing is more clear here --
thing.key === keyis an easy-to-see proof thatthingisT & { key: K }and the call should be legal.benwainwright commentedon Nov 22, 2021
Ryan Cavanaugh (@RyanCavanaugh) I've just played with that in the playground; I'm happy that this satisfies the use case I was trying to articulate :)
benwainwright commentedon Dec 22, 2021
Ryan Cavanaugh (@RyanCavanaugh) if I wanted to have a go at implementing this myself, what areas of the codebase would I need to
look at?
I appreciate that it might be beyond me as a new contributor, but I'd be interested in at least having a poke to see if it's doable
Oblarg commentedon Dec 27, 2022
I'd also like to have a go at this if possible, or at least get an update on whether this is on the roadmap. Here's a similar case:
Any thoughts of the feasibility of making the inferred function type behave better here along with fixing the issue?
johnw42 commentedon May 11, 2023
It kind of seems to me like the original code should work, but the issue can be worked around using the
&operator, leading to much simpler code:On the other hand, the same idea doesn't work on Eli Barnett (@Oblarg)'s example. This still fails:
I think the original example also fails for another unrelated reason, because the switch statement here also breaks, but it works if the literal variables in the return statements are replaced with
type:johnw42 commentedon May 11, 2023
On the third hand, I think the factory function example makes more sense using function overloading:
This loses some generality, but I don't think it's useful generality, because
shapealready has to handleShapesubtypes on a case-by-cases basis. Adding overloads instead of a fancy generic signature is a little more verbose but much more readable, and it's also more flexible, because it lets you do things like add a "rectangle" type with length and width parameters: