TypeScript + hyperappでのactionの書き方ベタープラクティス

# 結論

TypeScriptでhyperappを使うときは、下記のような形式でactionを書くようにすると、型推論が正しく行われ、入力補完やビルド時の型エラーチェックが最大限に効いてくれるようになります。

```tsx
import { app, h, ActionResult } from "hyperapp";

// Stateの宣言
interface MyState {
  count: number;
}
// Stateの初期化
const state: MyState = {
  count: 0
}

// 引数の型を指定できるaction型
type Act<Params = void> = (
  data?: Params
) =>
  | ((state: MyState, actions: MyActions) => ActionResult<MyState>)
  | ActionResult<MyState>

// Actionsの型宣言
interface MyActions {
  down:  Act<number>; 
  up:    Act<number>; 
  reset: Act; 
}
// Actionsの実装
const actions: MyActions = {
  down:  (value) => (state) => ({ count: state.count - value }),
  up:    (value) => (state) => ({ count: state.count + value }),
  reset: () => ({ count: 0 })
}

// View
const view = (state: MyState, actions: MyActions) => (
  <div>
    <h1>{state.count}</h1>
    <button onclick={() => actions.down(1)}>-</button>
    <button onclick={() => actions.up(1)}>+</button>
    <button onclick={() => actions.reset()}>Reset</button>
  </div>
)

// 起動
app(state, actions, view, document.body);
```

※TypeScript 2.9.2 + hyperapp 1.2.6で確認しています
※より良い書き方があれば、コメントや[Twitter]()等で教えてください:pray:

# まえがき

TypeScriptでhyperappを使い始めたときに、最初にとても悩んだのは、actionの書き方です。

普通に動かしたいだけであれば簡単です。
TypeScriptはJavaScriptのスーパーセットなので、公式READMEのサンプルを参考に、JavaScriptの例と同じように書けばよいだけです。

```typescript
const state = {
  count: 0
}

const actions = {
  down:  value => state => ({ count: state.count - value }),
  up:    value => state => ({ count: state.count + value }),
  reset: () => ({ count: 0 })
}
```

しかしこの書き方だと、型付けが適切に行われず、TypeScript上で扱いにくい箇所がいくつかあります。

1. `value`（actionの引数）がany型になっている。そのため、actionの呼び出し時に、引数として文字列やオブジェクトを渡してもビルドエラーにならない。
2. actionの中で使う`state`がany型になっている。そのため、`state`に存在しないプロパティを取得しようとしてもビルドエラーにならない。また、取得した値もany型になる。
3. actionの戻り値が不正な値でもビルドエラーにならない。

このため、下記のようなコードでもビルドが通ってしまい、容易に実行時エラーを発生させてしまいます。

```tsx
const state = {
  count: 0
}

const actions = {
  down:  value => state => ({ count: state.count - value.trim() }), // numberに存在しないtrim関数を呼んでいる
  up:    value => state => ({ count: state.cunt + value }), // プロパティ名 (count) をミスタイプしている
  reset: () => ('invalid return value') // hyperappのactionは文字列を返してはならない
}

//...

actions.up('invalid parameter'); // valueに文字列を渡している
```

せっかくTypeScriptを使うのですから、できるだけ型推論を効かせて、型付けの利点（入力補完やビルド時のエラーチェック）を最大限活用したいところです。そのためには、どのように書けばよいのでしょうか？


# 広く知られた方法

これに対する広く知られた対策として、下記のようなものがあります。

* `state`, `actions`について型を宣言する。（interfaceやtypeを使用する）
* hyperapp組み込みのActionsType型を`actions`に適用する。

この対策を適用したサンプルコードがこちらです。

```tsx
import { app, h, ActionsType } from "hyperapp";

// Stateの宣言
interface MyState {
  count: number;
}
// Stateの初期化
const state: MyState = {
  count: 0
}

// Actionsの型宣言
interface MyActions {
  down:  (value: number) => MyState; 
  up:    (value: number) => MyState;
  reset: () => MyState;
}
// Actionsの実装
const actions: ActionsType<MyState, MyActions> = {
  down:  (value: number) => (state) => ({ count: state.count - value }),
  up:    (value: number) => (state) => ({ count: state.count + value }),
  reset: () => ({ count: 0 })
}

// View
const view = (state: MyState, actions: MyActions) => (
  <div>
    <h1>{state.count}</h1>
    <button onclick={() => actions.down(1)}>-</button>
    <button onclick={() => actions.up(1)}>+</button>
    <button onclick={() => actions.reset()}>Reset</button>
  </div>
)

// 起動
app(state, actions, view, document.body);
```

（ActionsType型の定義の内容について知りたい方は、hyperappの型定義ファイル [hyperapp.d.ts][] を参照してください）

この書き方はWeb上でもサンプルコードとして記載されていることがある[^1]ため、広く知られており、現在hyperappでコードを書いている人の中にも、この書き方を行っている人は多いのではないかと思います。

……が、実は**この書き方は不完全です**。実際にこのコードを使用してみると、下記のようなことがわかります。

*   `MyActions`での宣言と`actions`での実装で、引数の型が違ってもエラーになりません。

    ```tsx
    interface MyActions {
      down: (value: number) => MyState; 
      up:   (value: number) => MyState;
    }
    
    const actions: ActionsType<MyState, MyActions> = {
      down: (value: string) => (state) => ({ count: state.count - value }), // stringなのにビルドが通る
      up:   () => (state) => ({ count: state.count + value }) // 引数がないのにビルドが通る
    }
    ```
 
*   `actions`で`value`の型宣言を省略した場合、インターフェースからの型推論が効かず、`value`がany型になります。

    ```tsx
    interface MyActions {
      up: (value: number) => MyState;
    }
    
    const actions: ActionsType<MyState, MyActions> = {
      up: (value) => (state) => ({ count: state.count + value.trim() }) // valueがany型なので、number型に存在しないtrimメソッドを呼んでもエラーにならない
    }
    ```

*   actionが不正な型の戻り値を返してもエラーになりません。

    ```tsx
    interface MyActions {
      up: (value: number) => MyState;
    }
    
    const actions: ActionsType<MyState, MyActions> = {
      up: (value: number) => (2) // オブジェクトでない値を返してもエラーにならない
    }
    ```

これはおおむね**hyperappのActionsType型を使っているのが原因です**。

実はhyperapp組み込みのActionsType型は、TypeScriptの制約のためか、かなり柔軟な（＝型制約の弱い）定義となっています。そのため、ユーザー側でこれを使ってactionsを宣言すると、上記のような副作用を引き起こしてしまいます。
そのため、**ActionsType型はユーザー側で使用するべきではありません。**hyperappのシステム内部で使用するために用意された型と考えたほうがよいでしょう。

# 改良案（ベタープラクティス）

上記の問題を避け、正しく型が適用されるためには、まずActionsType型を使わずにMyActionsを宣言する必要があります。
具体的には、下記のような変更を行います。

* `interface MyActions`の中では、hyperapp組み込みのActionType型を使って各actionを宣言するようにします。
* `const actions`の型を`MyActions`に変更します。

```tsx
import { app, h, ActionType } from "hyperapp";

//...

// Actionsの型宣言
interface MyActions {
  down:  ActionType<MyState, MyActions>; 
  up:    ActionType<MyState, MyActions>; 
  reset: ActionType<MyState, MyActions>; 
}
// Actionsの実装
const actions: MyActions = {
  down:  (value: number) => (state) => ({ count: state.count - value }),
  up:    (value: number) => (state) => ({ count: state.count + value }),
  reset: () => ({ count: 0 })
}
```

これにより、まず下記のような宣言がエラーとなるようになります。

```tsx
const actions: MyActions = {
  down:  (value: number) => (state) => (1), // hyperappのactionは数値を返せないためエラー
  up:    (value: number) => (state) => ({ count: 'invalid' }), // MyState.countプロパティはnumber型のためエラー
  reset: () => ({ count: 0 })
}
```

ただし、これだけではactionを実際に呼び出す時に、引数（`value`）の型を検証することができません。

```tsx
const view = (state: MyState, actions: MyActions) => {
  actions.down('invalid'); // 文字列を渡しているがエラーにならない
  actions.up(); // 引数を省略してもエラーにならない
}
```

これは、hyperapp組み込みのActionType型が下記のような定義となっており、actionの引数の型が常にanyとなっているためです。

```ts:hyperapp/hyperapp.d.ts
export type ActionType<State, Actions> = (
  data?: any
) =>
  | ((state: State, actions: Actions) => ActionResult<State>)
  | ActionResult<State>
```

hyperappの組み込み型を使う限り、この問題を避けることはできません。そこで、hyperapp組み込みのActionType型を参考にして**独自のaction型を定義する**ことによって、引数の型も指定できるようにします。

```tsx
import { app, h, ActionType, ActionResult } from "hyperapp";

// ...

// 引数の型を指定できるaction型
type Act<State, Actions, Params = void> = (
  data?: Params
) =>
  | ((state: State, actions: Actions) => ActionResult<State>)
  | ActionResult<State>

// Actionsの型宣言
interface MyActions {
  down:  Act<MyState, MyActions, number>; 
  up:    Act<MyState, MyActions, number>; 
  reset: Act<MyState, MyActions>;
}
// Actionsの実装
const actions: MyActions = {
  down:  (value) => (state) => ({ count: state.count - value }),
  up:    (value) => (state) => ({ count: state.count + value }),
  reset: () => ({ count: 0 })
}
```

ここでは`Act`という独自型を新しく定義することによって、`value`の型を指定できるようにしています。
（`Params = void` という書き方は、TypeScript 2.3以降で利用可能です）

ついでに`State`と`Actions`の型を固定して、`Act`に毎回これらの型を渡さなくてもよいようにしておきましょう。

```tsx
// 引数の型を指定できるaction型
type Act<Params = void> = (
  data?: Params
) =>
  | ((state: MyState, actions: MyActions) => ActionResult<MyState>)
  | ActionResult<MyState>

// Actionsの型宣言
interface MyActions {
  down:  Act<number>; 
  up:    Act<number>; 
  reset: Act; 
}
// Actionsの実装
const actions: MyActions = {
  down:  (value) => (state) => ({ count: state.count - value }),
  up:    (value) => (state) => ({ count: state.count + value }),
  reset: () => ({ count: 0 })
}
```

ようやく、最初の「結論」で提示したサンプルコードに辿り着きました！:clap:
これにより各actionの`value`, `state`, 戻り値について、正しく型推論が行われ、入力補完やビルド時のエラーチェックが正しく効くようになります。

```tsx
const actions: MyActions = {
  down:  (value) => (state) => ({ count: state.count - value.trim() }), // number型にtrimというメソッドはないためエラー
  up:    (value: string) => (state) => ({ count: state.count + value }), // 引数がnumber型でないためエラー
  reset: () => (state) => ({ count: 0 }),
}
```

ただし、この書き方でも、**戻り値に余分なプロパティがついている場合**はビルドエラーとならないことにご注意ください。

```tsx
const actions: MyActions = {
  down: (value) => (state) => ({ count: state.count - value, unknownProp: 1 }), // MyStateにunknownPropは存在しないが、エラーとはならない（Partial<State>と矛盾しないため）

  //...
}
```

※なお、実際にこの書き方を使うときには、`type Act`の宣言は別の型宣言ファイル（`src/typings/○○.d.ts`）に分けておくようにすると、見通しがよくなって良いと思います。


# おわりに

TypeScriptの強みは、強力な型システムによる入力補完やビルド時のエラーチェックにあるので、その強みを最大限に活かせる書き方を見つけたい（そのほうが後々楽になる！）……と強く思ったことから、この記事を書き始めました。
この記事が、同じくTypeScriptでhyperappを使い始める人のために、少しでも役に立てば幸いです:smile:

なお、もし「もっと良い書き方がある」「ここはこう書くとより良くなる」という方がいらっしゃいましたら、コメントや[Twitter][]からご意見ください。必要に応じて参考にさせていただきます。

# 補足：残課題

以下は現時点での残課題です。気が向いたら追記/再検討します。

* まだコードに改善できそうな箇所がある

    *   型宣言と実装を別々に書かないといけないのが面倒。できれば`interface MyActions`を書かずに、`const actions`の実装を1度書くだけで済ませたい（DRY）
    *   viewで受け取るactionsの型が`MyActions`となっているが、これは厳密には正しくない。ここで受け取るactionsの型はwired actions（配線されたアクション）なので、MyActionsではなく別の型となっているはず。
* 複数ファイルに分けたときの書き方のサンプルを作りたい
* [Nested Actions][]を使った場合のサンプルも作りたい
* 英語訳


[Twitter]: https://twitter.com/tetradice
[hyperapp.d.ts]: https://github.com/hyperapp/hyperapp/blob/5fa6109623484797b4da526398f6b8eab03678bf/hyperapp.d.ts#L47-L71
[Nested Actions]: https://github.com/hyperapp/hyperapp/blob/master/README.md#nested-actions
[^1]:
    参考：
    [TypescriptでHyperappを書く | log.pocka.io](https://log.pocka.io/posts/hyperapp-with-typescript)
    [Hyperapp &amp; Typescriptを試す - 🎶爆音駆動開発🎶](https://log.pocka.io/posts/hyperapp-with-typescript)