-
-
Save kourge/2702b9fc769673e84a3f316f285610c4 to your computer and use it in GitHub Desktop.
export interface Meta<T> { | |
meta: T; | |
} | |
export type AnyType = string | symbol; | |
export interface Action<Type extends AnyType, Payload> { | |
type: Type; | |
payload: Payload; | |
error?: boolean; | |
} | |
export type AnyAction = Action<AnyType, any>; | |
export interface Reducer<Type extends AnyType, State> { | |
(state: State, action: Action<Type, any>): State; | |
} | |
export type AnyReducer = Reducer<AnyType, any>; | |
/*****************************************************************************/ | |
type State = number; | |
namespace State { | |
/* State operators */ | |
export function increment(state: State): State { | |
return state + 1; | |
} | |
export function decrement(state: State): State { | |
return state - 1; | |
} | |
export function add(state: State, amount: string): State { | |
return amount === 'two' ? state + 2 : state; | |
} | |
export function assign(state: State, newState: State): State { | |
return newState; | |
} | |
/* State reducer implemented in terms of operators */ | |
export function reducer(state: State = 0, action: Actions.All): State { | |
switch (action.type) { | |
case Actions.INCREMENT: return increment(state); | |
case Actions.DECREMENT: return decrement(state); | |
case Actions.ADD: return add(state, action.payload); | |
case Actions.ASSIGN: return assign(state, action.payload); | |
default: return state; | |
} | |
} | |
} | |
namespace Actions { | |
/* Valid action type strings */ | |
export const INCREMENT: 'increment' = 'increment'; | |
export const DECREMENT: 'decrement' = 'decrement'; | |
export const ADD: 'add' = 'add'; | |
export const ASSIGN: 'assign' = 'assign'; | |
/* Valid actions, associating type string to payload type */ | |
export type Increment = Action<typeof INCREMENT, void>; | |
export type Decrement = Action<typeof DECREMENT, void>; | |
export type Add = Action<typeof ADD, string>; | |
export type Assign = Action<typeof ASSIGN, State>; | |
/* All possible valid actions as a tagged union */ | |
export type All = Increment | Decrement | Add | Assign; | |
} |
benbayard
commented
Sep 13, 2016
@benbayard: That's a good point. I can straight up remove decorators from this.
Yeah, this is much nicer
You guys are going deep into typescript.
isn't there still a type disconnect between the action and reducer? meaning if i change the type of the action.payload, typescript will still compile, even though the reducer is using a different type?
it looks like even if it is enforcing it, it's not per state method, meaning i could change the action.payload type of add to the type of assign, and still assign the add object or vice versa with typescript being happy with that.
@adamduffy: can you give an example? My understanding is that inside the reducer, on the line case Actions.ADD: return add(state, action.payload)
, action.payload
is narrowed down to string
, whereas on the line case Actions.ASSIGN: return assign(state, action.payload)
it is instead narrowed down to State
.
ok, it seems to be tied by sharing type State = number;
, but if actions and reducers are in separate files, who owns that definition?
also, since error?: boolean
instead of error?: Error
(?!?!?!), shouldn't interface action have payload: Payload | Error
. that makes the state methods uglier.
is the idea that we would write 70 lines of code for every 4 lines of logic? or is there an easy way to wrap this into creator methods that give you everything for free?
On actions and reducers being in separate files: we're converging on the consensus that they should be in the same file, as demonstrated by ducks. The example in this gist follows ducks in spirit but not in word.
On errors: per the FSA definition, when error === true
, payload: Error
. So to base off of the definition in this gist, an ErrorAction
is:
export interface ErrorAction<Type extends AnyType> extends Action<Type, Error> {
error: true;
}
/*
which expands to: {
type: Type;
payload: Error;
error: true;
}
*/
On boilerplate: the example has three main things: the state operators, the reducer, and the action types. The action types, to a certain extent, are hard to metaprogram away, but not impossible. The state operators can be inlined into the reducer, but are deliberate kept as separate functions since this example is meant to live in a component. The reducer is the bare minimum, but if it performs pure replacement / assignment, most of it can be metaprogrammed away.
Let me expand on metaprogrammability with type safety in mind.
Actions
If you think of actions as operators, they break down to:
- Null payload, where the type string is the only important part, like unary operators (hereafter "effect")
- Non-null payload, where both the type string and the payload matter, like binary operators, which furthermore breaks down into two more categories
- Replacement payload semantics, where the payload is extracted and that is the new state (hereafter "assignment")
- Complex payload semantics, where the payload is extracted and combined with the existing state to produce the new state
Action types
Action type strings such as export const INCREMENT: 'increment' = 'increment'
cannot be metaprogrammed away. Their definition, on both a type level and a value level, must be explicit.
An effect's type can be metaprogrammed away: export type Effect<Type extends AnyType> = Action<Type, void>
. An action such as export type Increment = Action<typeof INCREMENT, void>
can thus be simplified down to export type Increment = Effect<typeof INCREMENT>
.
An assignment's type is not meaningfully metaprogrammable. Its shape is always Action<Type, State>
, where State
is the same as a reducer's state.
State operators
We define separate state operators in UI since that allows consumers to immutably produce new states based on an existing state without having to worry about any of the copying semantics. If you are defining a state / action / reducer triple (or "duck", whatever you call it) outside of UI, state operators don't really need to exist if you don't see a reason for that kind of decoupling.
Reducers
A large number of our reducers are assignment reducers, which means they only handle assignment actions. That is to say, these reducers are nothing more than glorified action payload extractors that take data out of an action and then tell Redux "this is the new state now". (In the web-app
codebase, reducerForPromise
partially addresses this problem.) The bulk of our reducer boilerplate comes from writing assignment reducers handling assignment actions. This is also very simple to metaprogram away:
export function assignment<Type extends AnyType, State>(state: State, action: Action<Type, State>): State {
return action.payload;
}
This simple assignment reducer works on any state type. It simply disregards what we currently have and heralds the action's payload as the new state. There are only two remaining metaprogramming opportunities:
- An assignment reducer that returns the current state (or default value) instead of extracting the action payload if and only if the action's type string does not match its expectation
- An assignment reducer with a default value of your choosing (with lazy evaluation)