Reputation: 171
I want to declare function type like
type ReviewChangeHandler =
| ((newReview: Review, oldReview: null, options: {type: 'CREATE'}) => void)
| ((newReview: Review, oldReview: Review, options: {type: 'EDIT'}) => void)
| ((newReview: null, oldReview: Review, options: {type: 'DELETE') => void)
I want the type of newReview
and oldReview
argument to be inferred by the value of options.type
, so I can write code like:
switch (options.type) {
case 'CREATE':
// `newReview` inferred to type `Review` and `oldReview` inferred to type `null`
case 'EDIT':
// `newReview` inferred to type `Review` and `oldReview` inferred to type `Review`
case 'DELETE:
// `newReview` inferred to type `null` and `oldReview` inferred to type `Review`
How can I achieve such behavior?
Upvotes: 1
Views: 61
Reputation: 328132
There's a bunch going on here. First of all, I'm going to give Review
a dummy definition so we can work with it:
interface Review {
name: string;
}
Then, your union type of functions ReviewChangeHandler
is unfortunately not going to be useful to you. You presumably don't want your handler to be able to handle just one of those parameter lists, right? You want it to handle all of those parameter lists. That is, you want a function of type ReviewChangeHandler
to be the intersection of the three call signatures, not the union of them:
type ReviewChangeHandler =
& ((newReview: Review, oldReview: null, options: { type: 'CREATE' }) => void)
& ((newReview: Review, oldReview: Review, options: { type: 'EDIT' }) => void)
& ((newReview: null, oldReview: Review, options: { type: 'DELETE' }) => void)
TypeScript considers the intersection of functions to be the same as a function with multiple call signatures; that is, it's an overloaded function. To write such a function you can declare multiple call signatures and then a single implementation signature that can accept any of the call signatures' inputs and return any of the call signatures' outputs. Like this:
// three call signatures
function reviewChangeHandler(newReview: Review, oldReview: null, options: { type: "CREATE" }): void;
function reviewChangeHandler(newReview: Review, oldReview: Review, options: { type: "EDIT" }): void;
function reviewChangeHandler(newReview: null, oldReview: Review, options: { type: "DELETE" }): void;
// one implementation signature
function reviewChangeHandler(newReview: Review | null, oldReview: Review | null, options: { type: "CREATE" } | { type: "EDIT" } | { type: "DELETE" }) {
// impl here
}
You can double check that the above function declarations matches the new ReviewChangeHandler
intersection type by assigning it to a variable of that type:
const check: ReviewChangeHandler = reviewChangeHandler; // okay
Now you would like the compiler to be able to infer narrower types for newReview
and oldReview
based on the type of options.type
. Unfortunately this cannot really happen directly. The compiler will not understand that separate union-typed variables are correlated this way (see microsoft/TypeScript#30581 for discussion on the lack of support for correlated union types). Instead you might want to package your separate union-typed variables into a single object of a discriminated union type.
A plausible first attempt at this would be to just put newReview
, oldReview
, and options
as properties of an object:
const reviewChangeAttempt = {
newReview,
oldReview,
options
};
But you'd need to switch
on reviewChangeAttempt.options.type
, a nested discriminant property. And currently TypeScript does not recognize subproperties like this as valid discriminants. See microsoft/TypeScript#18758 for a feature request to support this.
If we want to gain the benefits of a discriminated union, we need to move the options.type
subproperty up one level. So this gives us the following type:
type ReviewChangeObj = {
newReview: Review;
oldReview: null;
type: "CREATE";
} | {
newReview: Review;
oldReview: Review;
type: "EDIT";
} | {
newReview: null;
oldReview: Review;
type: "DELETE";
}
and a possible implementation like this:
const reviewChange = {
newReview,
oldReview,
type: options.type
} as ReviewChangeObj;
Note that I have to use a type assertion to convince the compiler that reviewChange
is a valid ReviewChangeObj
. The compiler's lack of ability to follow the correlation between newReview
, oldReview
, and options.type
means that any repackaging of these into a type the compiler does understand would cause the compiler to object without something like a type assertion.
Also note that this throws away anything else that you might have put into options
; if you have other stuff in there you can hold onto it by making ReviewChangeObj
elements have both an options
and a type
property. But I digress.
After this, finally, you get the inference you want:
function reviewChangeHandler(newReview: Review, oldReview: null, options: { type: "CREATE" }): void;
function reviewChangeHandler(newReview: Review, oldReview: Review, options: { type: "EDIT" }): void;
function reviewChangeHandler(newReview: null, oldReview: Review, options: { type: "DELETE" }): void;
function reviewChangeHandler(newReview: Review | null, oldReview: Review | null, options: { type: "CREATE" } | { type: "EDIT" } | { type: "DELETE" }) {
const reviewChange = {
newReview,
oldReview,
type: options.type
} as ReviewChangeObj;
switch (reviewChange.type) {
case 'CREATE': {
console.log("CREATING NEW REVIEW: " + reviewChange.newReview.name);
break;
}
case 'EDIT': {
console.log("EDITING OLD REVIEW: " + reviewChange.oldReview.name + " AND NEW REVIEW: " + reviewChange.newReview.name);
break;
}
case 'DELETE': {
console.log("DELETING OLD REVIEW: " + reviewChange.oldReview.name);
}
}
}
You can see that inside the case
statements the compiler understands exactly when reviewChange.newReview
and reviewChange.oldReview
are going to be Review
and when they are going to be null
.
Upvotes: 3