Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TypeScript definitions #1413

Merged
merged 12 commits into from
Feb 26, 2016
81 changes: 81 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export interface Action {
type: string;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? Do we expect action type to always be string, or should it be any?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe

export interface Action<T> {
    type: T;
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only case I see for this is your example with enum:

enum ActionType {
   A, B, C
}

But where would it be useful? We can use it in reducer:

function reducer(state, action: Action<ActionType>) {
  // ...
}

But that would be incorrect: action argument here is not only your user-defined actions, it can also be {type: '@@redux/INIT'} or any other action used by third-party redux library.

}


/* reducers */

export type Reducer<S> = <A extends Action>(state: S, action: A) => S;

export function combineReducers<S>(reducers: {[key: string]: Reducer<any>}): Reducer<S>;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that reducers map should be defined separately:

export interface ReducersMapObject {
    [key: string]: Reducer<any>;
}

export function combineReducers<S, M extends ReducersMapObject>(reducers: M): Reducer<S>;

So anyone can easily make his own type for this corresponding with his State type if one was defined:

interface MyReducers extends ReducersMapObject {
    'posts': Reducer<Post[]>;
    'user': Reducer<MyUser>;
     
}

const reducers: MyReducers = ;

const myRootReducer = combineReducers<Reducer<State>, MyReducers>(reducers);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see one drawback here: until TypeScript supports default type parameters we'd have to always specify second parameter which adds verbosity.

How about overloads:

export function combineReducers<S>(reducers: ReducersMapObject): Reducer<S>;
export function combineReducers<S, M extends ReducersMapObject>(reducers: M): Reducer<S>;

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overloading makes sense 👍

But have to be well tested ;)



/* store */

export interface Dispatch {
<A>(action: A): A;
<A, B>(action: A): B;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Middleware definition below seems to be enough, but…

interface Middleware<S> extends Function {
    (store: MiddlewareAPI<S>): (next: Dispatch) => Dispatch;
}

It actually does not return a dispatch but function mapping input of type A to output of type B:

interface Middleware<S, A, B> extends Function {
    (store: MiddlewareAPI<S>): (next: Dispatch) => (action: A) => B;
}

But in this case Middleware will always have to be parametrised with both type parameters. We can avoid this ( but I'm NOT sure if we should ) in the same manner as with Dispatch:

interface Middleware extends Function {
    <S, A, B>(store: MiddlewareAPI<S>): (next: Dispatch) => (action: A) => B;
}

It's not always so easy to add static type definitions to code written in dynamically typed language… ;)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try to implement thunk middleware in TypeScript as an example. Start without any types:

const thunkMiddleware = ({dispatch, getState}) =>
  (next) => (action) => {
    return typeof action === function ? action(dispatch, getState) : next(action)
  }

Now what types can we add here? Keep in mind that there may me other middlewares applied before thunk, so dispatch here can potentially accept anything, e.g. promises. Same for next, same for action.
Middleware is standalone and doesn't know anything about dispatch type prior to when it was applied.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// in our typings
interface Middleware<S, A, B> extends Function {
    (store: MiddlewareAPI<S>): (next: Dispatch) => (action: A) => B;
}
import { MyState } from './wherever-my-state-is-declared'

type ThunkAction = ((d: Dispatch, gs: () => MyState) => ThunkAction) | Object;

const thunkMiddleware:  Middleware<MyStore, ThunkAction, ThunkAction> = 
  ({dispatch, getState}) =>
    (next) => (action) => {
      return typeof action === function ? action(dispatch, getState) : next(action)
    }

Does it do the job ?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shows that elasticity of thunkMiddleware stays in oposition to type safety. Anyway it can be always done in that way:

import { MyState } from './wherever-my-state-is-declared';

const thunkMiddleware:  Middleware<MyStore, any, any> = 
  ({dispatch, getState}) =>
    (next) => (action) => {
      return typeof action === function ? action(dispatch, getState) : next(action)
    }

;)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is — thunkMiddleware is standalone and it can't have knowledge of what MyStore is.


export interface Store<S> {
dispatch: Dispatch;
getState(): S;
subscribe(listener: () => void): () => void;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more lil suggestion here - lets create type/interface for function returned from subscribe so user can declare variable which will hold reference to it in future:

// index.d.ts
export interface Unsubscribe extends Function {
    (): void;
}

export interface Store<S> {
    
    subscribe(listener: () => void): Unsubscribe;
    
}

// real example from my code:
@Component({selector: 'my-app', templateUrl: '/src/app.html'})
export class App implements OnInit, OnDestroy {
    constructor(@Inject('AppStore') private appStore: Store<IAppState>) {}
    

    // I can declare it with proper type;
    private unsubscribe: Unsubscribe;

    // and assign value later
    public ngOnInit(): void {
        this.unsubscribe = this.appStore.subscribe(() => …doSomethingWith(this.appStore.getState()) );
    }

    public ngOnDestroy(): void {
        this.unsubscribe();
    }

}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense. One question: what's the reasoning behind extends Function? Isn't it redundant when interface already has call signature?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not test it but i think that Function has few things like bind that are not declared with just call signature… But I may be wrong. It may be redundant anyway.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've checked, call signature is enough.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

replaceReducer(reducer: Reducer<S>): void;
}

export interface StoreCreator {
<S>(reducer: Reducer<S>): Store<S>;
<S>(reducer: Reducer<S>, initialState: S): Store<S>;

<S>(reducer: Reducer<S>, enhancer: StoreEnhancer): Store<S>;
<S>(reducer: Reducer<S>, initialState: S, enhancer: StoreEnhancer): Store<S>;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can be simplified to

export interface StoreCreator {
  <S>(reducer: Reducer<S>, enhancer?: StoreEnhancer): Store<S>;
  <S>(reducer: Reducer<S>, initialState: S, enhancer?: StoreEnhancer): Store<S>;
}

while still providing the same type information and it better describes the actual API.
(and improving error messages, Typescript doesn't handle errors for overloads especially well)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

export type StoreEnhancer = (next: StoreCreator) => StoreCreator;

export const createStore: StoreCreator;


/* middleware */

export interface MiddlewareAPI<S> {
dispatch: Dispatch;
getState(): S;
}

export interface Middleware {
<S>(api: MiddlewareAPI<S>): (next: Dispatch) => <A, B>(action: A) => B;
}

export function applyMiddleware(...middlewares: Middleware[]): StoreEnhancer;


/* action creators */

export interface ActionCreator {
<O>(...args: any[]): O;
}

export function bindActionCreators<
T extends ActionCreator|{[key: string]: ActionCreator}
>(actionCreators: T, dispatch: Dispatch): T;


Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action creators:

I think that overloading is much better than union:

// or maybe "<A  extends Action>" but due to middleware issues better without "extends Action"
export interface ActionCreator<A> extends Function {
    (...args: any[]): A;
}

export interface ActionCreatorsMapObject {
    [key: string]: <A>(...args: any[]) => A;
}

export interface ActionCreatorsBinder {
    <A>(actionCreator: ActionCreator<A>, dispatch: Dispatch): ActionCreator<A>;
    <M extends ActionCreatorsMapObject>(actionCreators: M, dispatch: Dispatch): M;
    (actionCreators: ActionCreatorsMapObject, dispatch: Dispatch): ActionCreatorsMapObject;
}

export function bindActionCreators: ActionCreatorsBinder;

I've tested it with such piece of useless code:

const oneCreatorA = bindActionCreators<Action>(a => a, store.dispatch);
const oneCreatorB = bindActionCreators(oneCreatorA, store.dispatch);
const oneCreatorC = bindActionCreators(oneCreatorB, store.dispatch);

interface Ott extends ActionCreatorsMapObject {
    one: IActionCreator<Action>;
    two: IActionCreator<Action>;
    three: IActionCreator<Action>;
    four: IActionCreator<Action>;
}

const xCreatorA = bindActionCreators({
    one: a => a,
    two(b: Action) {return b;},
    three: oneCreatorA
}, store.dispatch);

const cMap: Ott = {
    one: a => a,
    two(b: Action) {return b;},
    three: oneCreatorA,
    four: oneCreatorC
};

const xCreatorB: ActionCreatorsMapObject = bindActionCreators(xCreatorA, store.dispatch);
const xCreatorC: Ott = bindActionCreators(cMap, store.dispatch);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export interface ActionCreatorsBinder {
    <A>(actionCreator: ActionCreator<A>, dispatch: Dispatch): ActionCreator<A>;
    <M extends ActionCreatorsMapObject>(actionCreators: M, dispatch: Dispatch): M;
    (actionCreators: ActionCreatorsMapObject, dispatch: Dispatch): ActionCreatorsMapObject;
}

Third overload seems redundant here, we can omit type parameter in second overload and it will be inferred as ActionCreatorsMapObject.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also consider the difference between

<A>(actionCreator: ActionCreator<A>, dispatch: Dispatch): ActionCreator<A>;

and

<A extends ActionCreator<any>>(actionCreator: A, dispatch: Dispatch): A;

I guess the latter is stronger because let's you constraint not only return type of ActionCreator, but its full signature including argument types.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second one here:

<A extends ActionCreator<any>>(actionCreator: A, dispatch: Dispatch): A;

is not typesafe as it enforces any.

I think we should find better way…

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aikoven also you said

Third overload seems redundant here, we can omit type parameter in second overload and it will be inferred as ActionCreatorsMapObject.

And that is great :)
It gives possibility to ignore type parameter if one chooses straight ActionCreatorsMapObject.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<A extends ActionCreator<any>>(actionCreator: A, dispatch: Dispatch): A;

is not typesafe as it enforces any.

I'm not sure I understand. This signature allows doing

bindActionCreators<ActionCreator<MyAction>>(...)

and even

bindActionCreators<(text: string) => MyAction>(...)

So type parameter is not enforced.

In contrast, if we used this signature:

<A>(actionCreator: ActionCreator<A>, dispatch: Dispatch): ActionCreator<A>;

to bind action creator of type (text: string) => MyAction, then bindActionCreators would return value of type (...args: any[]) => MyAction, effectively erasing constraints on its arguments.

Also, I found that we don't cover cases when bindActionCreators argument and return value have different types, e.g. if action creator returns thunk, then bound action creator will return what thunk returned.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right. My mistake:

<A extends ActionCreator<any>>(actionCreator: A, dispatch: Dispatch): A;

Is totally ok.

Also, I found that we don't cover cases when bindActionCreators argument and return value have different types, e.g. if action creator returns thunk, then bound action creator will return what thunk returned.

So maybe third overload:

<A extends ActionCreator<any>, B extends ActionCreator<any>>(
    actionCreator: A, 
    dispatch: Dispatch
): B;

/* compose */

// from DefinitelyTyped/compose-function
// Hardcoded signatures for 2-4 parameters
export function compose<A, B, C>(f1: (b: B) => C,
f2: (a: A) => B): (a: A) => C;
export function compose<A, B, C, D>(f1: (b: C) => D,
f2: (a: B) => C,
f3: (a: A) => B): (a: A) => D;
export function compose<A, B, C, D, E>(f1: (b: D) => E,
f2: (a: C) => D,
f3: (a: B) => C,
f4: (a: A) => B): (a: A) => E;

// Minimal typing for more than 4 parameters
export function compose<I, R>(f1: (a: any) => R,
...functions: Function[]): (a: I) => R;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most inner and the returned function does not necessarily take just a single argument, see https://github.com/reactjs/redux/pull/1399/files

So for all these the returned function must have an untyped parameterlist of ...args to not create false negatives that can only be solved by casting to Function

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like this maybe:

…
    f4: (a: A, ...args: any[]) => B): (a: A, ...args: any[]) => E;

and

…
    ...functions: Function[]): (a: I, ...args: any[]) => R;

It is really hard to type it strictly… Maybe it should be done just like tuples are in Scala - with limit to ~24 composed functions ?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But then you assume that the rightmost function takes at least one argument, which might be false.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "Predictable state container for JavaScript apps",
"main": "lib/index.js",
"jsnext:main": "es/index.js",
"typings": "./index.d.ts",
"files": [
"dist",
"lib",
Expand Down Expand Up @@ -88,6 +89,7 @@
"babel-plugin-transform-es2015-unicode-regex": "^6.3.13",
"babel-plugin-transform-object-rest-spread": "^6.3.13",
"babel-register": "^6.3.13",
"chai": "^3.5.0",
"cross-env": "^1.0.7",
"es3ify": "^0.2.0",
"eslint": "^1.10.3",
Expand All @@ -99,6 +101,8 @@
"isparta": "^4.0.0",
"mocha": "^2.2.5",
"rimraf": "^2.3.4",
"typescript": "^1.7.5",
"typescript-definition-tester": "0.0.3",
"webpack": "^1.9.6"
},
"npmName": "redux",
Expand Down
12 changes: 12 additions & 0 deletions test/typescript.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as tt from 'typescript-definition-tester'


describe('TypeScript definitions', () => {
it('should compile against index.d.ts', (done) => {
tt.compileDirectory(
__dirname + '/typescript',
fileName => fileName.match(/\.ts$/),
() => done()
)
})
})
22 changes: 22 additions & 0 deletions test/typescript/createStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {createStore, Action} from '../../index.d.ts'

interface AddTodoAction extends Action {
text: string;
}

function todos(state: string[] = [], action: Action): string[] {
switch (action.type) {
case 'ADD_TODO':
const addTodoAction = <AddTodoAction>action;
return state.concat([addTodoAction.text]);
default:
return state
}
}

let store = createStore(todos, ['Use Redux']);

store.dispatch({
type: 'ADD_TODO',
text: 'Read the docs'
})
35 changes: 35 additions & 0 deletions test/typescript/loggerMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
applyMiddleware, createStore,
Middleware, Dispatch
} from "../../index.d.ts";

export const loggerMiddleware: Middleware = ({getState}) => {
return (next: Dispatch) =>
(action: any) => {
console.log('will dispatch', action)

// Call the next dispatch method in the middleware chain.
let returnValue = next(action)

console.log('state after dispatch', getState())

// This will likely be the action itself, unless
// a middleware further in chain changed it.
return returnValue
}
}

function todos(state: any, action: any) {
return state;
}

let store = createStore(
todos,
['Use Redux'],
applyMiddleware(loggerMiddleware)
);

store.dispatch({
type: 'ADD_TODO',
text: 'Understand the middleware'
})
22 changes: 22 additions & 0 deletions test/typescript/subscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {createStore} from "../../index.d.ts";

const store = createStore((state: any) => state, {
some: {deep: {property: 42}}
});

function select(state: any) {
return state.some.deep.property
}

let currentValue: number;
function handleChange() {
let previousValue = currentValue;
currentValue = select(store.getState());

if (previousValue !== currentValue) {
console.log('Some deep nested property changed from', previousValue, 'to', currentValue)
}
}

let unsubscribe = store.subscribe(handleChange);
handleChange();
31 changes: 31 additions & 0 deletions test/typescript/thunkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
Middleware, MiddlewareAPI,
applyMiddleware, createStore, Dispatch
} from "../../index";


type Thunk<S> = <O>(dispatch: Dispatch, getState?: () => S) => O;


const thunkMiddleware: Middleware =
<S>({dispatch, getState}: MiddlewareAPI<S>) =>
(next: Dispatch) =>
<A>(action: A | Thunk<S>) =>
typeof action === 'function' ?
(<Thunk<S>>action)(dispatch, getState) :
next(action)


function todos(state: any, action: any) {
return state;
}

let store = createStore(
todos,
['Use Redux'],
applyMiddleware(thunkMiddleware)
);

store.dispatch<Thunk<any>, void>((dispatch: Dispatch) => {
dispatch({type: 'ADD_TODO'})
})