LoginSignup
12
11

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-07-19

結論

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

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の例と同じように書けばよいだけです。

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の戻り値が不正な値でもビルドエラーにならない。

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

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に適用する。

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

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での実装で、引数の型が違ってもエラーになりません。

    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 }) // 引数がないのにビルドが通る
    }
    
  • actionsvalueの型宣言を省略した場合、インターフェースからの型推論が効かず、valueがany型になります。

    interface MyActions {
      up: (value: number) => MyState;
    }
    
    const actions: ActionsType<MyState, MyActions> = {
      up: (value) => (state) => ({ count: state.count + value.trim() }) // valueがany型なので、number型に存在しないtrimメソッドを呼んでもエラーにならない
    }
    
  • actionが不正な型の戻り値を返してもエラーになりません。

    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に変更します。
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 })
}

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

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

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

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

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

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

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

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以降で利用可能です)

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

// 引数の型を指定できる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, 戻り値について、正しく型推論が行われ、入力補完やビルド時のエラーチェックが正しく効くようになります。

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 }),
}

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

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を使った場合のサンプルも作りたい

  • 英語訳

12
11
2

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
12
11