ngrx payload in reducer action not compiling
The problem is that the behavior of the code and its type annotations are at cross purposes with one another.
Actually, I would say that the code is excessively annotated.
Union types and the case analysis that they enable work via type inference and control flow based type analysis, two of TypeScript's most powerful features. The process by which the language eliminates possibilities from the union is known as narrowing.
As the code suggests, a union type can be broken down into its constituents by performing a value test against a property known as the discriminant.
A discriminant is a property whose type has a finite set of possible values with each value generally corresponding to a case of the union.
The type string
is not a valid discriminant, but the type "hello world"
is because, as a supertype of all possible strings, a union of string types including string
collapses to just string
. For example, the type string | "hello world"
is exactly the type string
.
When we define a const
or a readonly
property in TypeScript, the compiler infers its type to be the type of the initializing literal.
Consider:
const kind = "first";
Here the type of kind
is not string
but "first"
.
Similarly, given
class Kindred {
readonly kind = "first";
}
The type of the property kind
is not string
but "first"
.
In our code, the discriminant is a property named type
that is defined by each constituent of the union.
But while, you have correctly provided unique values for each member, you have over-specified them with type annotations that prevent narrowing.
What you have:
export class AddSuccessAction {
public readonly type: string = ADD_SUCCESS;
constructor(public payload: Mission) {}
}
export class AddFailedAction {
public readonly type: string = ADD_FAILED;
constructor(public payload: string) {}
}
What you want:
export class AddSuccessAction {
readonly type = ADD_SUCCESS;
constructor(public payload: Mission) {}
}
export class AddFailedAction {
readonly type = ADD_FAILED;
constructor(public payload: string) {}
}
A working switch
switch (action.type) {
// action.type is `"[Mission] AddSuccess" | "[Mission] AddFailed"`
// action.payload is `string | Mission`
case missionActions.ADD_SUCCESS:
// action.type is `"[Mission] AddSuccess"`
// action.payload is `Mission`
const mission = action.payload;
}
Why this matters:
A string literal type is a common and idiomatic way discriminate the possibilities of a union but, by declaring the implementing properties as being of type string
, a type that is a super type of all string literal types, we inhibited type inference and thereby prevented narrowing. Note that string
is not a type that can be narrowed from.
In general, it is best to leverage TypeScript's type inference when a value has an initializer. In addition to enabling the expected scenario, you will be impressed by the amount of bugs that the type inferencer will catch. We should not tell the compiler more than it needs to know unless we intentionally want a type that is more general than what it infers. With --noImplicitAny
it will always let us know when we need to specify extra information in the form of type annotations.
Remarks:
You can technically specify the types and still retain the narrowing behavior by specifying the literal value as the type of the const
or the readonly
property. However, this increases maintenance costs and is rather redundant.
For example, the following is valid:
export const ADD_FAILED: "[Mission] AddFailed" = "[Mission] AddFailed";
but you are just repeating yourself unnecessarily.
In their answer, ilyabasiuk provides some great reference links on the use of literal types specifically with ngrx and how it evolved over recent iterations of the TypeScript language.
To understand why literal types are inferred in immutable positions, consider that it enables powerful static analysis and thus better error detection.
Consider:
type Direction = "N" | "E" | "S" | "W";
declare function getDirection(): Direction;
const currentDirection = getDirection();
if (currentDirection === "N") { // currentDirection is "N" | "E" | "S" | "W"
}
// Error: this is impossible, unreachable code as currentDirection is "E" | "S" | "W"
// the compiler knows and will give us an error here.
else if (currentDirection === "N") {
}
Here is really good description of migration, that you are trying to do https://github.com/ngrx/example-app/pull/88#issuecomment-272623083
You have to do next changes:
- delete type definition for action const string;
- delete type definition for type property;
- add /* tslint:disable:typedef*/ in the beginning of file if your tslint configured to not allow missed type definition (yep it looks not very good, but if I have to choose between strong rules for type definition in action files an support types in reducers I will definitely choose second one);
So You will get something like:
/* tslint:disable:typedef*/
export const ADD_SUCCESS = "[Mission] AddSuccess";
export const ADD_FAILED = "[Mission] AddFailed";
export class AddSuccessAction implements Action {
public readonly type = ADD_SUCCESS;
constructor(public payload: Mission) {}
}
export class AddFailedAction implements Action {
public readonly type = ADD_FAILED;
constructor(public payload: string) {}
}
// More actions are defined here but im not gonna copy all
...
export type Actions =
AddSuccessAction |
AddFailedAction;
Aluan Haddad has rpovided good explanation why it works so in post above.