Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

TypeScript で polyfill やトランスパイルをどう扱えばいいか

In short: ES2017 が動く環境を対象にビルドするなら

例として ES2017 が動く環境を対象にビルドを考える。

  • tsconfig の target に es2017 を指定
  • tsconfig の lib に es2017 を指定
    • dom も必要であれば加える
    • 他の機能、例えば Array.prototype.flat が欲しくなったら、手動で polyfill を追加した後に tsconfig の lib に es2019.array を加える
  • eslint-plugin-compat を導入する
    • サポート対象のブラウザ環境で Web API が使えるかどうかを確認するために使う
    • polyfill の入れ忘れ防止になる

こうすれば、ES2017 より後に追加されたシンタックスはトランスパイルされるので安心して使える。もし ES2017 より後に追加された標準組み込みオブジェクトやメソッドを使っていたらコンパイルが落ちるし、各種 Web API は eslint-plugin-compat が弾いてくれる。だいぶ盤石なはず。

整理: ECMAScript に含まれるもの、含まれないもの

まず、「ECMAScript」がどこまでを含んでいるかをまとめた。

  • 標準組み込みオブジェクトは ECMAScript に含まれる(Promise や Math、String など)
  • 各種シンタックスやオブジェクトのメソッドも ECMAScript に含まれる(オプショナルチェーン演算子や Array.prototype.flat など)
  • Web API は ECMAScript に含まれない(DOM API や IntersectionObserver など)
    • DOM API は WHATWG が定める DOM Living Standard に規定されている。
    • IntersectionObserver は W3C が定めている。
    • History API は HTML Living Standard が定めている。

まとめると標準組み込みオブジェクト、シンタックス、メソッドが ECMAScript に含まれていて、Web API は含まれていない。TypeScript は基本的に ECMAScript だけを扱っている。Web API には関与していない。

TypeScript のコンパイル時の役割

TypeScript はコンパイル時に大まかに2つの役割を果たす。

  • シンタックスのトランスパイル (オプショナルチェーン演算子(?.)など)
  • ECMAScript に入っている標準組み込みオブジェクトやメソッドが target、lib のバージョン内にない場合にコンパイルを失敗させる

TypeScript は polyfill を入れたりはしないが、target、lib のバージョン内にない標準組み込みオブジェクトがを使っていたらコンパイルを通さない。この場合、手動で polyfill を入れて動くようにした後に lib へ該当するものを追加してコンパイルを通るようにする。

上記の通り、TypeScript は基本的に Web API について関知しない。例えば lib: ["dom"] を指定すると、target のバージョンに関わらず IntersectionObserver が扱えてしまう。これ以上はどうしようもないので eslint-plugin-compat で対処する。

satisfies は msw でモックを書くときとかに使える

import { setupServer } from 'msw/lib/node';
import { rest } from 'msw';

type ArticleResponse = { title: string; id: string };
setupServer(
  rest.post('/article', (_req, res, ctx) =>
    res(
      ctx.json({ title: 'mocked article', id: 'id' } satisfies ArticleResponse)
    )
  )
);

satisfies を使えない場合、const response: ArticleResponse = { /* */ } のように一時変数を用意する必要がある。

こんな感じで、unknown あるいは any を受け付けているところに制約を設ける目的で使うことができる。

@testing-library/react の debug が途中で途切れてしまう問題について

debug の出力には文字数制限があります。それを回避する方法について紹介します。

in short: debug ではなく prettyDOM(baseElement)) を使う

const { debug } = render(<HelloWorld />);
debug();

import { prettyDOM } from '@testing-library/react';
const { baseElement } = render(<HelloWorld />);
console.log(prettyDOM(baseElement, Infinity));

debug()console.log(prettyDOM(baseElement, Infinity)) に直します。

debug はただのショートカット

debug は console.log(prettyDOM(baseElement)) のショートカットです (参照)

prettyDOM 関数 には maxLength という引数を持ちます。名前の通り、maxLength は出力の最大文字数です。maxLength は デフォルトが 7000 なので、debug 関数では7000文字しか表示されません。

7000 文字の制限を突破するには、prettyDOM の第二引数 (=maxLength) に Infinity を渡せば OK です。ただし debug 関数からでは maxLength に Infinity を渡せないので、debug を prettyDOM に書き直します。

import { prettyDOM } from '@testing-library/react';
const { baseElement } = render(<HelloWorld />);
console.log(prettyDOM(baseElement, Infinity));

うまく抽象化できてないコードは読みづらい

短いコードのほうが読みやすい傾向はあります。しかしながら、 短くて誤読しやすいコードよりは、長いけど誤読しないコードのほうが可読性が高いです。 今回はその話をします。

「短ければ可読性が高い」というのは勘違い

短くても可読性が低いコードはあります。例えば以下の2つの main 関数を比べてみます。

短いけど抽象化に失敗しているコード:

const main = async () => {
  const _article = await fetch('/article');
  const article = transformItem(_article);
};

長いけど分かりやすいコード:

const main = async () => {
  const _article = await fetch('/article');
  const article = {
    ..._article,
    fetchedAt: Date.now(),
    id: `${item.title}__${item.body}`,
  }
};

main 関数は前者のほうが短いです。しかし、transformItem の中身を読まなければ動作が分かりません。

読まずに済むコードを増やす

上記の短いけど抽象化に失敗しているコードを改善します。例えばこんな感じです。

const main = async () => {
  const _article = await fetch('/article');
  const article = addUniqueId(addArticleFetchedTime(_article, Date.now()));
};

「短いけど抽象化に失敗しているコード」と同じくらいの長さになりましたが、main 関数を読むだけでコードの動作をだいたい予想できます。

このように、コードを短くするには詳細を読まずに予想でカバーできる部分を増やす必要があります。 そして、詳細を読まなかったときに誤読させてはいけません。

抽象化に失敗しているケース

抽象化に失敗しているケースは他にもあります。いくつか見ていきましょう。

  • フォールバックが含まれていることが表現されていない
  • 関数の動作が引数によって変わりうる

フォールバックが含まれていることが表現されていない

const App = () => {
  const cart = response.item.length > 0 ? response.item.join(', ') : 'カートは空です';
  /* ... */
  return <div>カート内のアイテム: {cart}</div>;
}

コンポーネントの実装を読むとき、return の直後から読む人がほとんどだと思います。しかし、return 以降だけ読むと このコンポーネントは「カートは空です」と表示されうることを読み取れません。 また、「カートは空ですと表示されることが cart を読めばわかる」ということも読み取れません。

この場合、フォールバックを cart に含めないように修正すると分かりやすくなります。

const App = () => {
  const cart = response.item.length > 0 ? response.item.join(', ') : null;
  /* ... */
  return <div>カート内のアイテム: {cart !== null ? cart : 'カートは空です'}</div>;
}

これなら cart の宣言部分を読まなくてもコードの意図をおよそ誤読せずに理解できます。

あるいは、ちょっと苦しいですが変数名を変えるのも手の一つです(フォールバックを含めないようにした方が大抵良いです)

const App = () => {
  const cartMessage = response.item.length > 0 ? response.item.join(', ') : 'カートは空です';
  /* ... */
  return <div>カート内のアイテム: {cartMessage}</div>;
}

この例を見てわかる通り、やはり「フォールバックが存在する」と変数名で表現するのは無理が生じやすいです。

関数の動作が引数によって変わりうる

const fetchArticle = (articleId?: string) => {
  if (typeof articleId !== 'string') {
    return;
  }
  return fetch(`/article/${articleId}`);
}

fetchArticle は動作が articleId によって変わっています(undefined の場合は何もせず、string の場合は fetch する)。一般に 動作が変わることを関数名と引数だけでは予想できません。

解決するには、articleId を string にすれば良いです。

const fetchArticle = (articleId: string) => {
  return fetch(`/article/${articleId}`);
}

こういったコードは、最初は JavaScript で書かれていたことが多いです。TypeScript にするタイミングで直しましょう。