皆さんは Hyperapp V2 を使っていて「タイプセーフにしたいな」と思ったことはありますか?
私はあります。なのでタイプセーフなHyperTSAppを作りました。
2019/04/03 追記: npmライブラリ化しました→Typerapp
編集履歴
- 2019/01/04 型チェックが完全になっていない欠陥を追記
HyperTSApp?
おいおいHyperTSAppって何だよ?Hyperappの型定義書けばいいじゃん?と思ったそこのあなた!是非、型定義を書いてください。...上手く書けたのであれば、この記事はスルーしましょう。
私はHyperappの型定義に挑戦しましたが、(私の技量では)完全なタイプセーフを実現することはできませんでした。HyperappはJavaScriptで書かれており、柔軟な書き方ができるようになっています。柔軟なのは良いことなのですが、この柔軟性を型定義で表し、かつ、タイプセーフにしようとすると...頭がおかしくなって死にます。私は死にかけました。なのでHyperappがタイプセーフになるように改変したHyperTSAppを作ろうと思い至りました。
Hyperappの型定義が辛いポイント
Actionのパラメーターの省略とdataの存在
Actionのシグネチャはこうなっています。
(state[, props], data) => newState
これを見るとprops
が省略できるんだなと読み取れると思います。シンプルですね。これをTypeScriptで表現するとこんな感じでしょうか。
type Action<S, P, D> =
((state: S, data: D) => S)
| ((state: S, props: P, data: D) => S)
第2引数のprops
が省略できるので二つに分かれました。次にprops
を省略したActionを作ってみましょう。
const X: Action<{ a: number }, undefined, Event> = (state, data) => state
このコードを書くと、TypeScriptのコンパイラーがこんなことを言ってきます。
パラメーター 'state' の型は暗黙的に 'any' になります。
パラメーター 'data' の型は暗黙的に 'any' になります。
state
とdata
はanyになりましたとさ。めでたしめでたし。
Actionの呼び出し(dispatch)が隠ぺいされている
HyperappではActionの呼び出しにdispatch関数を使用しています。このdispatch関数、EffectとSubscriptionを定義するときぐらいにしか直接触れることができません。
例えば、buttonのclickでActionを呼び出すコードを書くと次のようになります。
<button onClick={[ActionName, { param: hoge }]} />
このコードは、Hyperappが内部でbutton.onclickイベントをリッスンしてdispatchの呼び出しに変換してくれます。疑似的に書くとこんな感じ。
onclick={ev => dispatch([ActionName, { param: hoge }], ev)}
これをタイプセーフにする型定義を書くのはかなり骨が折れます。Reactの型定義のようなものをHyperapp用に自作する必要が出てくるでしょう。回避策としては次のような関数を定義します。(Actionだけになっていますが、Hyperappはタプルでstateやeffectを使用できるのでもっと複雑になります)
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引数は死んでいます。
HyperTSApp
リポジトリ
上記リポジトリをnpm i
してnpm run start
でparcelが動きサンプルを実行します。
Action
HyperappのActionからdataを削除し、stateとparamsの2つのみに変更します。
export type Action<S, P = undefined> = (state: S, params: P) => S | [S, EffectObjectBase[]?]
Effect/Subscription
定義されたEffect/Subscriptionから厳密に型指定されたActionを生成したかったので、Effetc/Subscriptionはクラスに変更しました。
一定時間後にActionを実行するEffectは次のように定義します。
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メソッドで生成します。
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メソッドを使用します。
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になります。
app({
init: () => initState,
})
view
app関数で渡すviewはstateとdispatchを引数にもつ関数になります。Actionはdispatch関数を直接使用して呼び出します。見た目はHyperapp V1に近いです。
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に怒ってもらえます。型安全万歳
こういった改変が思い付きでできちゃう規模なのがHyperappのすごいところだと思います。すっごいミニマム。
あとはモジュール機能が欲しいですね。これが意外と難しい。Hyperappの方は外部ライブラリで提供するみたいですけど...
追記:型チェックが完全でない重大な欠陥
TypeScriptの仕様みたいなんですが、Actionでstateに存在しないプロパティを書いてもエラーにならないようです。
type Action<S> = (state: S) => S
const a: Action<{ a: number }> = state => ({ ...state, xxx: 1 })
xxx
というプロパティはstateに存在しないはずですが、エラーになりません。
ジェネリクスを使用しないシンプルな例。
type F = () => ({ a: number })
const f: F = () => ({ a: 1, xxx: 1 })
変数の代入でもワンクッション置くとエラーにならなくなるという仕様があるのが原因と思われます。
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 // エラーなし
どうしたらいいんですかね...?