コード中の定数の greppability と TypeScript の進化

TL; DR

コード中の定数の greppability

以下のように翻訳の ID を引数にとって, 各言語に翻訳されたメッセージを返す関数というのはよくあるのではないかと思います.

declare function translate(id: string): string;

そして (形式は何でも良いのですが) 各言語に対応した翻訳が書かれているファイルを, (方法は何でも良いのですが) translate が読み込んで使うとしましょう.

{
  "direction": "方向",
  "direction.left": "",
  "direction.right": ""
}

こうして画面上に各言語に対応したメッセージが表示できました. よかったですね.

const Direction: FC<{ dir: "left" | "right" }> = ({ dir }) => (
  <p>{translate("direction")}: {translate(`direction.${dir}`)}</p>
);

ところでソフトウェアは生き物なので, 生きている限りは画面上の表示も変更されたりして, 使わなくなった翻訳というのが往々にして発生します. こういったものが積もっていくと資源の無駄なので, 必要がなくなったら削除していきたいはずです.

ということで, 例えば direction.left という ID の翻訳が現在使われているか調べてみましょう. 最も素朴な方法はこの ID でソースコードを検索 (grep) することですが, 検索してみたところソースコード中には direction.left という文字列は見つかりませんでした. どうやら使われていないようなので削除しましょう!

...

となっては困りますね. とはいえ単純な grep で見つけられないとなると自動化できませんし, 人間が探すにしても空気を読む必要があって大変です.

ここで問題となっているのは translate(`direction.${dir}`) のように動的に ID 文字列を生成している部分です. もし greppability (grep 可能性) を確保したいのであれば, ここを translate(dir === "left" ? "direction.left" : "direction.right") のようにリテラルで与えるようにする必要があります.

このような greppability を保証するために translate の型定義を以下のように変更してみます.

declare function translate<T extends string>(id: StringLiteral<T>): string;

type StringLiteral<T extends string> = string extends T ? never : T;

ここで StringLiteral<T>Tstring の場合のみ never となり, string literal type やその union type であれば T 自体となる型です. こうして作られた translate は, 引数に漠然とした string は受け付けず, 必ず string literal type またはその union type といった, より具体的なものを受け取るようになります.

なぜこれで greppability を保証できるかというと, 動的に ID を生成すると型が曖昧になる = string 型になるということを利用しています.

// OK
translate("direction.left"); 

declare const dir: "left" | "right";
// `direction.${dir}` の型は string なのでエラー
translate(`direction.${dir}`);

よかったですね.

ここまでが一年前に考えていた話.

TypeScript の進化

さて TypeScript の進化はここ一年もめざましく, 先日 beta 版がリリースされた TypeScript 4.2 からは template literal expression に対して template literal type が付与されるようです.

どういうことかというと, こういうことです.

declare const dir: "left" | "right";
// `direction.${dir}` の型は `"direction.left" | "direction.right"` なので OK
translate(`direction.${dir}`); 

終了. 正直 TypeScript 4.1 の template literal types の導入の時点でこうなる予感はしてました.

ということで TypeScript の型を使った方法はもう未来がないので, せっかく紹介したところですが使わないでください. 他の方法, たとえば ESLint で no-restricted-syntax を使って雑に検査するのであれば以下のような感じになるかと思います.

{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.type='Identifier'][callee.name='translate'][arguments.length>0][arguments.0.type!='Literal']",
        "message": "Translation ID must be given as a literal expression."
      }
    ]
  }
}

良いニュースと悪いニュースがある, みたいな話でした. こちらからは以上です.

2020-02-13 追記

TypeScript 4.2 RC で上記の変更は一部 revert されました. とはいえ as const をつけることで beta と同様のより詳細な型の推論は行われるので, やはり型推論が不可能であるという前提に立った方法は不適当であることは変わらなそうです.

ここ 1 年くらいで作った有象無象まとめ

私が個人的に作ったものはいくつかはこのブログで紹介したりしていますが, そのように個別に紹介するほどでもない有象無象たちにも出番を与えてみようという企画です. だいたい 2019 年末ぐらいからのものが入っています.

tyde

github.com

型安全で自分好みなイベントエミッタが欲しくなったので作りました.

使い方は普通. event-kit のイベント購読をまとめる API が気に入っていたのでそれも実装しました.

import { Emitter, CompositeSubscription } from "@susisu/tyde";

const emitter = new Emitter<{ updated: number }>();
const sub = new CompositeSubscription();

sub.add(emitter.on("updated", value => {
  console.log(value);
}));
sub.add(emitter.on("updated", value => {
  console.log(value * 2);
}));

emitter.emit("updated", 42); // -> 42, 84

sub.unsubscribe();

その他の特徴として, バグの温床となる, あるいはリファクタリングの妨げとなる設計をしにくいように, API を調整したり意図的に機能を制限したりしています.

例えば, イベントハンドラの呼び出しは非同期に行われます (多くのイベントエミッタでは同期的).

次のようなコードを見てみましょう.

function update(): void {
  begin();

  doSomething();
  emitter.emit("updated");

  commit();
}

トランザクションのようなものの中で何かを変更してイベントを発行しています.

もしここでイベントハンドラが同期的に呼び出されていると, イベントハンドラ側から別の更新処理が実行され, トランザクションに予期しないものが混ざってしまう, あるいは多重にトランザクションが開始されることによるエラーなどが起こり得ます. これは単純にバグの原因となるだけではなく, それを回避するために約束事を作ったりすると, イベントの発行元とハンドラの結合性が増してしまい, 設計上も好ましくないと考えられます.

(commit() の後でイベントを発行すれば良いのはその通りです. そうすべきです. ただしフレームワークの側で begin()commit() を呼び出しているために, 利用者側では回避が難しいという場合もあります. 具体的には AngularJS の $digest() とか.)

このような問題は, イベントハンドラの呼び出しを非同期にして, 実行順を常に commit() の後にすることで一般的に解決できます. 私個人の経験では同期的で困ったことはあっても非同期で困ったことはないので, デフォルトで非同期で良いのではと考えています.

また, イベントハンドラの呼び出し順はいかなる方法でも保証できません (他のイベントエミッタでは priority を指定できることもある).

これはイベントハンドラ同士が呼び出し順に依存してしまうことで, リファクタリングなどで予期せず実行結果が変わってしまったり, あるいは暗黙的に密結合な箇所が作られてしまうということ回避するためのものです. 順序を保証したい場合は, ひとつのイベントハンドラの中で順序が重要な処理をあらわに記述するか, あるいはイベントを複数のステージに分割するべきです.

という感じです. イベントエミッタにまつわる不可視のスパゲッティコードを生み出すと本当に辛いので...

react-use-source-sink

github.com

React の useRef で作られる RefObject<T> ないし MutableRefObject<T> は読み書きが可能なオブジェクトであるため, 型引数 T に関して invariant です (厳密には TypeScript の仕様上 covariant として扱われてしまいますが). そのため以下のように, 一般的な型の ref を作って具体的な要素に渡すということはできません.

import { FC, useRef, useEffect } from "react";

const Hello: FC = () => {
  const ref = useRef<HTMLElement | null>(null);
  useEffect(() => {
    const elem = ref.current;
    // ...
  });
  return <p ref={ref}>Hello!</p>; // ここでエラー
};

これを回避するために, 読み取り専用の source と書き込み専用の sink に分離することで, このあたりをいい感じにしようというのが useSourceSink です.

import { FC, useEffect } from "react";
import { useSourceSink } from "@susisu/react-use-source-sink";

const Hello: FC = () => {
  const [source, sink] = useSourceSink<HTMLElement | null>(null);
  useEffect(() => {
    const elem = source();
    // ...
  });
  return (
    <p ref={sink}>Hello!</p>
  );
};

これだけ.

catcher

github.com

非同期のデータ取得の結果をキャッシュしたり, 複数同時に実行されるのを一つにまとめたりしたくなったので作りました.

import { Catcher } from "@susisu/catcher";

const catcher = new Catcher({
  fetcher: () => fetchFromSource(params),
});

// `fetchFromSource` が呼ばれてデータが取得される
const data1 = await catcher.fetch();

// キャッシュされたデータが返ってくる
const data2 = await catcher.fetch();

// `expire()` を呼び出すと再度 `fetchFromSource` が呼ばれる
catcher.expire();
const data3 = await catcher.fetch();

なんかこの手の実装は無限にありそうなんですが, フレームワーク非依存でパッと使えるものが見つけられなかったので...

tesseract

github.com

トランザクションみたいなものを実装するとき, 実行中に起こったエラーのハンドリングなどは大体パターン化されるものの毎回実装すると大変なので, その部分だけを抜き出して再利用可能にしたというものです.

README にいくつか例を書いていますが, たとえば複数の処理を実行して, 最終的に変更があったときだけイベントを発行すると行った処理を以下のように書くことができます.

import { Session } from "@susisu/tesseract";

const session = new Session<State>({
  initialize() {
    return dumpState();
  },
  finalize(oldState: State) {
    emitIfUpdated(oldState);
  },
  handleError(error, phase, oldState: State | undefined) {
    if (oldState === undefined) {
      return;
    }
    emitIfUpdated(oldState);
  },
});

function emitIfUpdated(oldState: State): void {
  const newState = dumpState();
  if (!newState.equals(oldState)) {
    emit();
  }
}

session.transact(() => {
  updateA(session);
  updateB(session);
});

なんか不意に作ったけど使わずにそれっきりになっている.

type-of-schema

github.com

TypeScript で JSON Schema をオブジェクトとして書いて, それを元に対応するデータの TypeScript 上の型を導出してやろうという試みです.

なんかこういう感じ. 実際のバリデーションには ajv とかを使う.

import { TypeOfSchema } from "@susisu/type-of-schema";

const schema = {
  type: "object",
  properties: {
    "a": { type: "number" },
    "b": { type: "string" },
  },
  required: ["b"],
} as const;

// T = { a?: number, b: string }
type T = TypeOfSchema<typeof schema>;

まあ TypeScript フレンドリーな (JSON Schema を使わない) バリデータもいくつかあると思うので, ふつうはそういうのを使った方が良いです.

use-debounced

github.com

なんか React コンポーネントで入力を debounce したいときに良いのが見つからなかったので作りました.

いくつか hooks を提供していますが, 例えば入力を debounce してサーバーに検索リクエストを送るみたいなのは以下のように書けます.

import { FC } from "react";
import { useDebouncedAsyncCall } from "@susisu/use-debounced";

const MyComponent: FC = () => {
  const [user, call, isWaiting] = useDebouncedAsyncCall({
    func: name => fetchUser(name).catch(() => "NOT FOUND"),
    init: "",
    wait: 1000,
  });
  return (
    <div>
      <p>
        <input
          type="text"
          defaultValue=""
          onChange={e => {
            call(e.target.value);
          }}
        />
      </p>
      <p>{isWaiting ? "..." : user}</p>
    </div>
  );
};

hookshelf

github.com

Context 経由で React Hooks を提供するやつです. 危なそうですね. 実際取り扱いにはそれなりに注意が必要です.

私は Custom Hooks の良いところとして, 抽象化レイヤーとしてはたらくということがあると思っています. 実際に複数のより基本的な Hooks を束ねた Custom Hooks を定義することで, 利用側からは実装の詳細を気にしなくてもよくなり, 人間の認知の上ではとても役に立ちます.

一方で実装上は単に複数の関数呼び出しをまとめただけなので, まったく抽象化レイヤーにはなってはいません. インターフェースがあるからといって, 我々が代替の実装を挟み込む余地はありません. 最終的には React が提供する useState などの基本的な Hooks に分解され, それらはすべて React の内部でハンドリングされます.

hookshelf はこの欠点を解消するために作りました. たとえば以下のような Hook があるとします.

import { hooks } from "./lib";

const { useBrowserFeature, useNetworkFetch, useComplexState } = hooks;

export function useMyHook() {
  const id = useBrowserFeature();
  const { data, error } = useNetworkFetch(id);
  const { state, dispatch } = useComplexState();
  // do something with data, error, state, and dispatch
  return ...;
}

useBrowserFeature などの Hooks は内部実装を気にすることなく使えているように見えます.

ところがこれについて例えばテストを書こうとすると, 利用している Hooks の内部の実装を完全に把握した上で, かなり大掛かりな実装をすることになります.

test("It works", () => {
  ... // ブラウザの機能やネットワークをモック

  const { result } = renderHook(useMyHook);
  expect(result.current).toEqual(...);

  ... // データが非同期に取得されるのを待つ
  expect(result.current).toEqual(...);

  ... // 状態を更新する
  expect(result.current).toEqual(...);

  ... // 状態を更新して assert を繰り返す
});

これでは大変なのでどうにかしたいですね.

ということで, なんかこういう感じで hooks に含まれる Hooks をラップした proxyHooks を作っておきます.

import { createHookshelf } from "@susisu/hookshelf";
import { hooks } from "./lib";

export const [HooksProvider, proxyHooks] = createHookshelf(hooks);

これらを使うのは単純に元の実装を置き換えればよいだけです. これだけで useMyHook は元と同じように動作します.

import { proxyHooks } from "./shelf"

const { useBrowserFeature, useNetworkFetch, useComplexState } = proxyHooks;

export function useMyHook() {
  const id = useBrowserFeature();
  const { data, error } = useNetworkFetch(id);
  const { state, dispatch } = useComplexState();
  // do something with data, error, state, and dispatch
  return ...;
}

テストでは先程 proxyHooks と同時に作った HooksProvider を使ってモック実装を差し込むことで, 元々の useBrowserFeature などの内部の詳細については踏み込むことなく, テストしたい状態をその場で作ってテストすることができます.

import { HooksProvider } from "./shelf";

function prepare({ id, data, error, state, dispatch }) {
  const hooks = {
    useBrowserFeature: jest.fn(() => id),
    useNetworkFetch: jest.fn(() => ({ data, error })),
    useComplexState: jest.fn(() => ({ state, dispatch })),
  };
  const Wrapper = ({ children }) => (
    <HooksProvider hooks={hooks}>{children}</HooksProvider>
  );
  return { hooks, Wrapper };
}

test("It returns some data created from fetched data and state", () => {
  const { hooks, Wrapper } = prepare({
    id: 42,
    data: { ... },
    error: undefined,
    state: { ... },
    dispatch: () => {},
  });
  const { result } = renderHook(useMyHook, { wrapper: Wrapper });
  expect(result.current).toEqual(...);
});

test("It returns another data in another state", () => {
  const { hooks, Wrapper } = prepare({
    id: 42,
    data: { ... },
    error: undefined,
    state: { ... },
    dispatch: () => {},
  });
  const { result } = renderHook(useMyHook, { wrapper: Wrapper });
  expect(result.current).toEqual(...);
});

よかったですね. ただし Rules of Hooks は守って楽しくデュエルしましょう.

まとめ

有象無象って言葉面白いですよね. 2020 年版の象の新種につけたい名前ランキングでは第 4 位だったそうです.