Typerapp: タイプセーフなHyperapp V2

以前に書いた「[Hyperapp(V2) をガッチガチのタイプセーフにした HyperTSApp を作る](https://qiita.com/diontools/items/b8619ec1569d0d2a03ef)」から、より改善したライブラリを作りました。

# Typerapp
Typerappは[Hyperapp V2](https://github.com/jorgebucaran/hyperapp/tree/V2)をタイプセーフになるように変更したWebフレームワークです。TypeScriptで書かれています。

[GitHubリポジトリ](https://github.com/diontools/typerapp)

サンプル: [![Edit typerapp-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/diontools/typerapp-sample/tree/master/?fontsize=14&module=%2Fsrc%2Findex.tsx)
最小サンプル: [![Edit typerapp-minimum-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/diontools/typerapp-minimum-sample/tree/master/?fontsize=14&module=%2Fsrc%2Findex.tsx)

試行錯誤[^1]の結果、CodeSandbox上で完璧に動作します。:v:

[^1]: npmパッケージ内のpackage.jsonのmainにコンパイル済みのJavaScriptファイルを、typesにTypeScriptのソースコードファイルを指定するとCodeSandboxでソースコードを読み込んでくれる。Why?

## インストール
```shell
npm install typerapp
```

> TyperappではNodeモジュール内のTypeScriptファイルを直接使用するので、Webpackのts-loaderを使用する場合は、[allowTsInNodeModules](https://github.com/TypeStrong/ts-loader#allowtsinnodemodules-boolean-defaultfalse)を有効にし、tsconfig.jsonで`node_modules/typerapp`をincludeしてください。Parcelではこれらの設定は不要です。

## Hyperappからの変更点
1. Actionから`data`引数を削除
2. `view`の引数に`dispatch`を追加
3. 純粋なDOMイベント

### Actionから`data`引数を削除
Actionの引数は2つだけになります。

Hyperapp:

```js
const Act = (state, { value }, data) => ({...})
```

Typerapp:

```typescript
const Act: Action<State, { value: number }> = (state, params) => ({...})
```

### `view`の引数に`dispatch`を追加
Hyperapp:

```js
app({
    view: state => ...
})
```

Typerapp:

```tsx
app<State>({
    view: (state, dispatch) => ...
})
```

### 純粋なDOMイベント
Typerappでは、VDOMのイベントに[Event](https://developer.mozilla.org/ja/docs/Web/API/Event)を引数に持つ関数を指定します。
そして、`dispatch`を使用してActionを呼び出します。

Hyperapp:

```tsx
const Input = (state, ev) => ({ ...state, value: ev.currentTarget.value })

app({
    view: state => <div>
        <input
            value={state.value}
            onInput={Input}
        />
    </div>
})
```

Typerapp:

```tsx
const Input: Action<State, string> = (state, value) => ({ ...state, value })

app<State>({
    view: (state, dispatch) => <div>
        <input
            value={state.value}
            onInput={ev => dispatch(Input, ev.currentTarget.value)}
        />
    </div>
})
```

## 型定義
タイプセーフなActions、Effects、Subscriptions、HTML要素などなど...

### Actions

型定義:

```typescript
export type ActionResult<S> = S | [S, ...Effect<any, any>[]]
export type Action<S, P = Empty> = (state: S, params: P) => ActionResult<S>
```

使い方:

```typescript
// without parameter
const Increment: Action<State> = state => ({ ...state, value: state.value + 1 })

// with parameter
const Add: Action<State, { amount: number }> = (state, params) => ({
    ...state,
    value: state.value + params.amount
})
```

### Effects

型定義:

```typescript
export type Effect<S, P = Empty> = [(props: P, dispatch: Dispatch<S>) => void, P]
```

Effectの宣言:

```typescript
// Delay Runner Props
export type DelayProps<S, P> = {
    action: EffectAction<S, P>
    duration: number
}

// Delay Effect Runner
const DelayRunner = <S, P>(props: DelayProps<S, P>, dispatch: Dispatch<S>) => {
    setTimeout(() => dispatch(props.action), props.duration)
}

// Delay Effect Constructor
export function delay<S, P>(action: DelayProps<S, P>['action'], props: { duration: number }): Effect<S, DelayProps<S, P>> {
    return [DelayRunner, { action, duration: props.duration }];
}
```

使い方:

```typescript
// Increment with Delay
const DelayIncrement: Action<State> = state => [
    state,
    delay(Increment, { duration: 1000 })
]

// Add with Delay
const DelayAdd: Action<State, { amount: number }> = (state, params) => [
    state,
    delay([Add, { amount: params.amount }], { duration: 1000 })
]
```

### Subscriptions

型定義:

```typescript
export type Subscription<S, P = Empty> = [(props: P, dispatch: Dispatch<S>) => () => void, P]
```

Subscriptionの宣言:

```typescript
// Timer Runner Props
export type TimerProps<S, P> = {
    action: EffectAction<S, P>
    interval: number
}

// Timer Subscription Runner
const timerRunner = <S, P>(props: TimerProps<S, P>, dispatch: Dispatch<S>) => {
    const id = setInterval(() => dispatch(props.action), props.interval)
    return () => clearInterval(id)
}

// Timer Subscription Constructor
export function timer<S, P>(action: TimerProps<S, P>['action'], props: { interval: number }): Subscription<S, TimerProps<S, P>> {
    return [timerRunner, { action, interval: props.interval }]
}
```

使い方:

```typescript
app<State>({
    subscriptions: state => [
        timer(Increment, { interval: 1000 })
    ]
})
```

### HTML要素

[DefinitelyTypedのReact](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react)からフォークして[Html.d.ts](https://github.com/diontools/typerapp/blob/master/types/Html.d.ts)を作成しました。

## 制限事項
TypeScriptはActionの超過プロパティをチェックしません。

```typescript
const Act: Action<State> = state => ({
    ...state,
    typo: 1 // no error!
})
```

回避策:

```typescript
// type alias for Action/ActionResult
type MyAction<P = Empty> = Action<State, P>
type MyResult = ActionResult<State>

// explicit return type
const Act: MyAction = (state): MyResult => ({
    ...state,
    typo: 1 // error
})
```

真の解決のためには、[Exact Types](https://github.com/Microsoft/TypeScript/issues/12936)へ投票してください。

## 追加機能
Typerappにはいくつか追加機能があります。

### actionCreator

`actionCreator`はシンプルなモジュール化関数です。

```tsx
// part.tsx
import { h, View, actionCreator } from 'typerapp'

type State = {
    foo: string,
    part: {
    	value: number
    }
}

const createAction = actionCreator<State>()('part')

const Add = createAction<{ amount: number }>(state => ({
    ...state,
    value: state.value + params.amount
}))

export const view: View<State> = ({ part: state }, dispatch) => <div>
    {state.value} <button onClick={ev => dispatch(Add, { amount: 10 })}>request</button>
</div>
```

### ActionParamOf
`ActionParamOf`型はEffect/Subscription ConstructorからActionのパラメーター型を取得します。

```typescript
import { ActionParamOf } from 'typerapp'
import { httpJson } from 'typerapp/fx'

// { json: unknown }
type ParamType = ActionParamOf<typeof httpJson>

const JsonReceived: Action<State, ParamType> = (state, params) => ({
    ...state,
    text: JSON.stringify(params.json)
})
```

### Helmet
`Helmet`はDOMのhead要素にレンダリングします。

```tsx
import { Helmet } from 'typerapp/helment'

app<State>({
    view: (state, dispatch) => <div>
        <Helmet>
            <title>{state.title}</title>
        </Helmet>
    </div>
})
```

パフォーマンス向上のためLazyを使用することをオススメします。

```tsx
const renderHead = (props: { title: string }) => <Helmet>
    <title>{props.title}</title>
</Helmet>

app<State>({
    view: (state, dispatch) => <div>
        <Lazy key="head" render={renderHead} title={state.title} />
    </div>
})
```

### Router
RouterはURLベースのルーティングSubscriptionです。Routerは[History](https://developer.mozilla.org/ja/docs/Web/API/History) APIによってURLからStateに反映します。

```tsx
import { createRouter, Link, RoutingInfo, Redirect } from 'typerapp/router'

// Update routing
const SetRoute: Action<State, RoutingInfo<State, RouteProps> | undefined> = (state, route) => ({
    ...state,
    routing: route,
})

// Create router
const router = createRouter<State, RouteProps>({
    routes: [{
        title: (state, params) => 'HOME',
        path: '/',
        view: (state, dispatch, params) => <div>home</div>,
    }, {
        title: (state, params) => 'Counter / ' + params.amount,
        path: '/counter/:amount',
        view: (state, dispatch, params) => {
            const amount = params.amount ? parseInt(params.amount, 10) : 1
            return <div>
            	<div>{state.value}</div>
                <button onClick={ev => dispatch(Add, { amount })}></button>
        	</div>
        },
    }, {
        title: (state, params) => 'Redirect!',
        path: '/redirect',
        view: (state, dispatch, params) => <Redirect to="/" />,
    }],
    matched: (routing, dispatch) => dispatch(SetRoute, routing),
})

app<State>({
    view: (state, dispatch) => <div>
        <div><Link to="/">Home</Link></div>
        <div><Link to="/Counter/10">Count10</Link></div>
        <div><Link to="/Redirect">Redirect</Link></div>
        {
            state.routing
                ? state.routing.route.view(state, dispatch, state.routing.params)
                : <div>404</div>
        }
    </div>
})
```

### CSS-in-JS
`style`は[Picostyle](https://github.com/morishitter/picostyle)からフォークした関数です。

```tsx
import { style } from 'typerapp/style'

// styled div
const Wrapper = style('div')({
    backgroundColor: 'skyblue',
    width: '50px',
})

// styled div with parameter
const StyledText = style<{ color: string }>('div')(props => ({
    color: props.color,
    transition: "transform .2s ease-out",
    ":hover": {
        transform: "scale(1.5)",
    },
    "@media (orientation: landscape)": {
        fontWeight: "bold",
    },
}))

app<State>({
    view: (state, dispatch) => <div>
        <Wrapper>
            <StyledText color="green">text</StyledText>
        </Wrapper>
    </div>
})
```

### ハイフン付けされたSVG属性の別名
TypeScriptはTSX上のハイフン付けされた属性をチェックしません。

型をチェックできるCamel-caseの属性を使用するために`typerapp/main/svg-alias`をインポートしてください。

以下では、`strokeWidth`と`strokeDasharray`が`stroke-width`と`stroke-dasharray`に変換されます。

```tsx
import "typerapp/main/svg-alias"

<svg x="0px" y="0px" width="200px" height="3" viewBox="0 0 200 1">
    <line
        x1="0"
        y1="0.5"
        x2="200px"
        y2="0.5"
        stroke="skyblue"
        strokeWidth={3}
        strokeDasharray={5}
    />
</svg>
```

### mergeAction
TyperappではActionに`data`が無いため、Effect/SubscriptionでActionによって値を返す場合、Actionパラメーターに戻り値を合成する必要があります。

この場合、`mergeAction`を使用することができます。

```typescript
import { EffectAction, Dispatch, Effect } from "typerapp"
import { mergeAction } from 'typerapp/fx/utils'

export type RunnerProps<S, P> = {
    action: EffectAction<S, P, { returnValue: number }>
}

const effectRunner = <S, P>(props: RunnerProps<S, P>, dispatch: Dispatch<S>) => {
    dispatch(mergeAction(props.action, { returnValue: 1234 }))
}

export function effect<S, P>(action: RunnerProps<S, P>["action"]): Effect<S, RunnerProps<S, P>> {
  return [effectRunner, { action }]
}
```


# おわりに
かなりHyperappに近い形でタイプセーフになってるんじゃないかと思います。
VDOMのイベントから`dispatch`を分離するだけで、Hyperappの型付け問題が8割解決しますね。（Hyperappがそうなるとは思えませんが... :sob:）
もしくはIntrinsicElementsにStateの型を渡せるようにできる天才どこかにいないかな:innocent: