LoginSignup
5
3

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-12-31

皆さんは 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' になります。

statedataanyになりましたとさ。めでたしめでたし。:innocent:

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を使用できるのでもっと複雑になります:innocent:

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
リポジトリ
上記リポジトリを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に怒ってもらえます。型安全万歳:raised_hands:
こういった改変が思い付きでできちゃう規模なのがHyperappのすごいところだと思います。すっごいミニマム。
あとはモジュール機能が欲しいですね。これが意外と難しい。Hyperappの方は外部ライブラリで提供するみたいですけど...

追記:型チェックが完全でない重大な欠陥

TypeScriptの仕様みたいなんですが、Actionでstateに存在しないプロパティを書いてもエラーにならないようです。:scream:

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 // エラーなし

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

5
3
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3