Panda Noir

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

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

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

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

短くても可読性が低いコードはあります。例えば以下の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 にするタイミングで直しましょう。

JSON.stringify の返り値は undefined になることがあるよ

JSON.stringify(undefined)undefined です。 以上です。

正答率10%。みんな undefined を渡したときの挙動を知らなかったっぽいですね(僕も知りませんでした)

補足

JSON.stringify に undefined を渡した時の挙動は ECMAScript (ECMA-262) の JSON.stringify でちゃんと定義されています。

他の例外パターンとして、循環があると TypeError が起きるようです。

a = [];
a[0] = a;
my_text = JSON.stringify(a); // throw a TypeError.

また、JSON.stringify(function() {})undefined になります。

さらに補足

JSON.stringify の型をみると string しか返ってこないことになっています。 2017 年に issue が建てられていますがまだ解決していません。https://github.com/microsoft/TypeScript/issues/18879

React のコールバックrefが呼び出されるタイミングは、コールバックrefの実装によって違う

コールバックrefの実装方法によって ref が渡されるタイミングが異なります。

  • インライン関数で渡した場合(<element ref={(ref) => {}} />): 再レンダリングされるたびに呼び出される
  • メモ化したり、クラスのメンバーに格納して各レンダリングで同じものを渡した場合: マウント時とアンマウント時にのみ呼ばれる

インライン関数で渡すと、再レンダリングのたびに2回呼び出されます。1回目の呼び出しでは null が渡され、2回目では element が渡されます。

どちらの実装方法でも、マウント時には element が渡され、アンマウント時には null が渡されます。

codesandbox で作ったデモ

参考にしたページ

https://ja.reactjs.org/docs/refs-and-the-dom.html#caveats-with-callback-refs

引数は固定だけど返り値でジェネリクスを使いたいケースがある

In short:

  • 関数が reference を返す場合(useRef の返り値など)は返り値のみにジェネリクスを使っても問題ない
  • fetch のような返り値が引数の内容によって決まるケースでも(型安全性は犠牲になるが)使うことがある。

ジェネリクスの一般的な用途

そもそもジェネリクスの目的は 「引数の型だけちょびっと異なる型を上手く扱いたい」 というのが原点です

const id = <T>(x: T) => x;
const toString = <T>(x: T) => `${x}`;

id 関数も toString 関数も、引数の型として number も string も受け付けています。

このように、ジェネリクスは引数の型を可変にするために使われるのが一般的 です。

引数にジェネリクスを使わないパターン

引数にジェネリクスを使わないパターンは ごく一部の例外を除いて存在しません (その例外についてが本題なので後述します)。

例えば常に例外を投げて失敗する async 関数を考えます

const throwError = <T>(): Promise<T> => {
    throw new Error();
};

型検査も通りますし、使うこともできます。ただ、Promise<T> の代わりに Promise<never> と書いても同じです。

const throwError = (): Promise<never> => {
    throw new Error();
};

このように、返り値にジェネリクスを使わない形に書き換えられるケースがほとんどです。

返り値にのみジェネリクスを使うケース: 返り値が reference のとき

見てきたように基本的に返り値にだけジェネリクスを使うケースはありませんが、レアな例外の一つが reference を返すケースです。React ユーザーであればすぐピンとくると思います。

たとえば const divRef = useRef<HTMLDivElement>(null) のようなコードを書いたことがありませんか? この場合、引数は null ですが、返り値が RefObject<HTMLDivElement> です。まさに引数にはジェネリクスを使わないのに返り値にジェネリクスを使うケースです。

このように、状態が変化しうるものが返り値のときは、返り値にだけジェネリクスが現れます。

返り値にのみジェネリクスを使うケース: fetch 関数

fetch 関数も、返り値にのみジェネリクスを使う例外の一つです。

const fetchAPI = <T>(endpoint: string): Promise<T> =>
  fetch(endpoint).then((res) => res.json());

ただし、この場合は実はジェネリクスを使わなくても書けます。

const fetchAPI = (endpoint: string) =>
  fetch(endpoint).then((res) => res.json());

この場合、Promise<any> が返ってきます。つまり、any を T に変更しているのです。実際には any なので当然、型安全性を損ないます。ただ、「API は滅多に変わらないから type guard なしでキャストしてしまおう」という判断は実際のプロダクトでも十分考えられます(type guard を手動で管理しようとすると結構な労力になるので…)

このように型安全性を多少犠牲にしつつ利便性をとる場合にも使われることがあります。当然ですが多用すべきではありません。

まとめ

ジェネリクスが返り値に現れた場合、それらはほとんどの場合 unknown あるいは never で置き換えることができます。reference を返すケースを除けば、型安全性のためにも返り値だけにジェネリクスを使うのはお勧めしません。