Hyperapp(V2) をガッチガチのタイプセーフにした HyperTSApp を作る

皆さんは Hyperapp V2 を使っていて「タイプセーフにしたいな」と思ったことはありますか？
私はあります。なのでタイプセーフなHyperTSAppを作りました。

**2019/04/03 追記: npmライブラリ化しました→[Typerapp](https://qiita.com/diontools/items/f8ef48644e07a1cdea45)**

# 編集履歴
* 2019/01/04 型チェックが完全になっていない欠陥を追記

# HyperTSApp？
おいおいHyperTSAppって何だよ？Hyperappの型定義書けばいいじゃん？と思ったそこのあなた！是非、型定義を書いてください。...上手く書けたのであれば、この記事はスルーしましょう。

私はHyperappの型定義に挑戦しましたが、（私の技量では）完全なタイプセーフを実現することはできませんでした。HyperappはJavaScriptで書かれており、柔軟な書き方ができるようになっています。柔軟なのは良いことなのですが、この柔軟性を型定義で表し、かつ、タイプセーフにしようとすると...頭がおかしくなって死にます。私は死にかけました。なのでHyperappがタイプセーフになるように改変したHyperTSAppを作ろうと思い至りました。

## Hyperappの型定義が辛いポイント
### Actionのパラメーターの省略とdataの存在

Actionのシグネチャはこうなっています。

```js
(state[, props], data) => newState
```

これを見ると`props`が省略できるんだなと読み取れると思います。シンプルですね。これをTypeScriptで表現するとこんな感じでしょうか。

```ts
type Action<S, P, D> =
    ((state: S, data: D) => S)
    | ((state: S, props: P, data: D) => S)
```

第2引数の`props`が省略できるので二つに分かれました。次に`props`を省略したActionを作ってみましょう。

```ts
const X: Action<{ a: number }, undefined, Event> = (state, data) => state
```

このコードを書くと、TypeScriptのコンパイラーがこんなことを言ってきます。

```
パラメーター 'state' の型は暗黙的に 'any' になります。
パラメーター 'data' の型は暗黙的に 'any' になります。
```

`state`と`data`は**any**になりましたとさ。めでたしめでたし。:innocent:

### Actionの呼び出し(dispatch)が隠ぺいされている
HyperappではActionの呼び出しにdispatch関数を使用しています。このdispatch関数、EffectとSubscriptionを定義するときぐらいにしか直接触れることができません。
例えば、buttonのclickでActionを呼び出すコードを書くと次のようになります。

```tsx
<button onClick={[ActionName, { param: hoge }]} />
```

このコードは、Hyperappが内部でbutton.onclickイベントをリッスンしてdispatchの呼び出しに変換してくれます。疑似的に書くとこんな感じ。

```ts
onclick={ev => dispatch([ActionName, { param: hoge }], ev)}
```

これをタイプセーフにする型定義を書くのはかなり骨が折れます。[Reactの型定義](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts)のようなものをHyperapp用に自作する必要が出てくるでしょう。回避策としては次のような関数を定義します。（Actionだけになっていますが、Hyperappはタプルでstateやeffectを使用できるのでもっと複雑になります:innocent:）

```tsx
function act<S, P, D>(action: [Action<S, P, D>, P]) {
    return action
}

<button onClick={act([ActionName, { param: hoge }])} />
```

これで型チェックはされるようになりますが、しょっちゅう書き忘れたりしてダメダメです。ぶっちゃけ面倒。

### Actionのdataは本質的にany
Actionのdata引数は呼び出し元によって何が入るのか変わるので本質的に**any**です。上記の通り、dispatchが隠ぺいされているのでタイプセーフにするのは本当に難しいです。
絶対タイプセーフにするマンとしては**anyは死**を意味します。つまりdata引数は死んでいます。:innocent:

# HyperTSApp
[![Edit HyperTSApp](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/diontools/HyperTSApp/tree/master/)
[リポジトリ](https://github.com/diontools/HyperTSApp)
上記リポジトリを`npm i`して`npm run start`でparcelが動きサンプルを実行します。

## Action
HyperappのActionからdataを削除し、stateとparamsの2つのみに変更します。

```tsx
export type Action<S, P = undefined> = (state: S, params: P) => S | [S, EffectObjectBase[]?]
```

## Effect/Subscription
定義されたEffect/Subscriptionから厳密に型指定されたActionを生成したかったので、Effetc/Subscriptionはクラスに変更しました。
一定時間後にActionを実行するEffectは次のように定義します。

```ts
const Delay = new Effect<{ interval: number }, { startTime: string }>((props, dispatch) => {
    const startTime = Date()
    setTimeout(() => dispatch(props.action, { ...props.params, startTime }), props.interval)
}, (props, runner) => ({
    effect: runner,
    ...props,
}))
```

Effectクラスの型引数に、Effectを生成するときに使用するパラメーターの型(**Props**)と、Actionを実行するときに合成するパラメーターの型(**ReturnProps**)を指定します。またコンストラクターに、Effect Runnerと、Effect Constructorを指定します。

定義されたEffectからActionを**createAction**メソッドで生成します。

```ts
const OnDelayed = Delay.createAction<State, { amount: number }>((state, params) => ({
    ...state,
    value: state.value + params.amount,
    text: params.startTime,
}))
```

createActionメソッドの型引数はActionの型引数と同様に機能しますが、paramsの型はReturnPropsと合成され、`{ amount: number } & { startTime: string }`という型になります。これはHyperappのdata引数の代替になります。

ActionからEffectを呼び出すときは**create**メソッドを使用します。

```ts
const DelayAdd: Action<State, { interval: number, amount: number }> = (state, params) => [
    state,
    [Delay.create({ action: OnDelayed, params: { amount: params.amount }, interval: params.interval })]
]
```

## init
app関数で渡すinitはパラメーターのないActionになります。

```ts
app({
    init: () => initState,
})
```

## view
app関数で渡すviewはstateとdispatchを引数にもつ関数になります。Actionはdispatch関数を直接使用して呼び出します。見た目はHyperapp V1に近いです。

```tsx
app({
    view: (state, dispatch) => (
        <div>
            <button onClick={ev => dispatch(Increment)}>increment</button>
            <button onClick={ev => dispatch(Add, { amount: 10 })}>add10</button>
            <button onClick={ev => dispatch(DelayAdd, { interval: 1000, amount: 50 })}>delayAdd</button>
            <p>value: {state.value}</p>
            <p>text: {state.text}</p>
        </div>
    ),
})
```

# 更なるタイプセーフ
HyperTSAppのリポジトリではbutton.onClickなどを型チェックするために、Reactの型定義をフォークしてHyperTSApp用のHTML周りの型定義を追加しています(Html.d.ts)。
見様見真似で書いているので不備がある可能性が高いです。

# おわりに
簡単なアプリではかなりタイプセーフになっていると思います。ちょっとでも入力ミスがあればTSCに怒ってもらえます。型安全万歳:raised_hands:
こういった改変が思い付きでできちゃう規模なのがHyperappのすごいところだと思います。すっごいミニマム。
あとはモジュール機能が欲しいですね。これが意外と難しい。Hyperappの方は外部ライブラリで提供するみたいですけど...

# 追記：型チェックが完全でない重大な欠陥
TypeScriptの仕様みたいなんですが、Actionでstateに存在しないプロパティを書いてもエラーにならないようです。:scream:

```ts
type Action<S> = (state: S) => S
const a: Action<{ a: number }> = state => ({ ...state, xxx: 1 })
```

`xxx`というプロパティはstateに存在しないはずですが、エラーになりません。

ジェネリクスを使用しないシンプルな例。

```ts
type F = () => ({ a: number })
const f: F = () => ({ a: 1, xxx: 1 })
```

変数の代入でも[ワンクッション置くとエラーにならなくなるという仕様](https://www.typescriptlang.org/docs/handbook/interfaces.html#excess-property-checks)があるのが原因と思われます。

```ts
type X = { a: number }
const x: X = { a: 1, xxx: 1 } // エラー：型 '{ a: number; xxx: number; }' を型 'X' に割り当てることはできません。

const temp = { a: 1, xxx: 1 }
const x2: X = temp // エラーなし
```

どうしたらいいんですかね...？:thinking: