🎄

NonNullable<T>と実例 assertExists() / TypeScript一人カレンダー

2022/12/23に公開約12,500字

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の17日目です。昨日は『intrinsicとは そしてUppercase<T>』を紹介しました。

NonNullable<T>

TypeScriptでは、Null- and undefined-aware typesという機構があります。これはTypeScript 2.0にて--strictNullChecksオプションと同時に実装され当時驚きを集めました。それまではstringnumberといった基礎的な型は検証できても、undefinednullのリスクについては従来のECMAScriptを書く場合と変わらなかったためです。4.9である現在はこのエピソードはただの昔話であり、TypeScriptでundefinednullを区別することは当たり前となっています。

さて、Partial<T>の回で紹介したなかでOptional指定や| undefinedは微妙な挙動差があると述べました。Optional指定を外すためのUtility TypesのひとつがRequired<T>です。そして| undefined| nullを外すためのUtility Typesのひとつが今回紹介するNonNullable<T>です。この型自体はTypeScript 2.8で追加されました。

https://www.typescriptlang.org/docs/handbook/utility-types.html#nonnullabletype

さっそくサンプルコードから挙動を確認しましょう。

type T11 = NonNullable<string>;
//   ^? string

type T12 = NonNullable<null>;
//   ^? never

type T13 = NonNullable<undefined>;
//   ^? never

type T14 = NonNullable<string | number | null | undefined>
//   ^? string | number

type T15 = NonNullable<string[] | null | undefined>
//   ^? string[]

type T21 = NonNullable<{ a?: string; }>
//   ^? { a?: string | undefined; }

type T22 = NonNullable<{ a: string | null; }>
//   ^? { a: string | null; }

type T23 = NonNullable<{ a: NonNullable<string | null>; } | null>
//   ^? { a: string; }

type T24 = NonNullable<Array<string | null>>
//   ^? (string | null)[]

NonNullable<T>という名前でありながら| undefinedも一緒に取り去ってくれます。どうしても厳密に区別したい場合は不適格ですが、概ね満足できるはずです。T12, T13のようにTnullundefinedのみを渡し、取り除いた結果何も残らないようであればneverが得られます。

T21, T22, T23のようにオブジェクト内はネストしないことがわかります。また、Optional指定がなくなるわけではありません。T24のように、Array<T>の中の| nullに対しても操作されません。なのでこういった状況で一斉にOptional指定や| nullUnion型を取り除きたいようであればRecursiveRequiredNonNullable<T>のような型を自作することで解決できます。

nullの可能性を除去する実装

コンポーネント内でNullableな変数を扱うハンドラ

TypeScript 2.0で採用されたnull, undefinedの区別はTypeScriptにとって非常に強力な機能追加でした。一方で少しでもnullundefinedの可能性があれば即座にエラー扱いにするTypeScriptコンパイラに対して古くからのECMAScriptでの開発者は冗長さを感じたりもしたようです。

何らかの関数で扱う変数がNullableである場合、値があることを確認しないと処理を書き進められません。例えば、Reactで作るショッピングアプリで商品を表示するコンポーネントItemCardの中で、買い物カゴに入れるボタンに対応するハンドラとしてhandleClickAddToCart()関数を実装したいとします。

import { useCallback } from "react";

type Item = {
  id: string;
  name: string;
  price: number;
};

type Props = {
  item: Item | null;
};

declare const useAddToCart: () => (item: Item) => void;

export function ItemCard({ item }: Props): JSX.Element {
  const addToCart = useAddToCart();

  const handleClickAddToCart = useCallback(() => {
    addToCart(item);
  }, [item]);

  const buttonLabel = "買い物カゴに追加";

  return item !== null ? (
    <div>
      <p>{item.name}</p>
      <p>{item.price}</p>
      <button onClick={handleClickAddToCart}>{buttonLabel}</button>
    </div>
  ) : (
    <div>
      <p>読込中</p>
      <button disabled={true}>{buttonLabel}</button>
    </div>
  );
}

このコンポーネントItemCardにて、買い物カゴに追加ボタンを押した際のハンドラhandleClickAddToCart()ではaddToCart(item)を実行し、カートに引数の商品を追加しています。しかしこのコードはエラーです。addToCart(null)は許容されていないためです。

雑なnull逃し

それでは、コンパイルが通るように修正しましょう。

const handleClickAddToCart = useCallback(() => {
  if (item === null) {
    return;
  }
  addToCart(item);
}, [item]);

これでOK。本当にそうでしょうか?

そもそも、itemnullであるとき、handleClickAddToCart()がバインドされたbutton要素はレンダリングされていません。レンダリングされるのは<button disabled={true}>の要素であるためです。ということはitemnullであるにも関わらずhandleClickAddToCart()が呼ばれてしまうという状況そのものが異常です。そのため、returnで「無かったこと」にしてはいけません。ここでは例外を投げるべきです。

例外を投げるように変更

const handleClickAddToCart = useCallback(() => {
  if (item === null) {
    throw new Error("loading of item should be completed");
  }
  addToCart(item);
}, [item]);

これでようやくOKと言えそうです。item === nullな状況でhandleClickAddToCart()が呼ばれること自体、通常のユースケースではありえないため、まずこの例外が投げられることはありません。もし万が一呼ばれるようであれば、なんらかの実装が誤っており、修正する必要があると気付けます。

nullであればreturnという実装を書いてしまう開発者は、筆者が数々の案件に関わってきた限りかなり多いという印象です。そういった状況ではまず「ここでそもそもnullになることはあるのか」と立ち止まり、どうあるべきかを再確認するのがよいです。

Assertion Functions

毎回if (v === null) { throw new Error("") }と書いていく…、これはもちろん大切なことなのですが、一方で毎回三行も消費して見づらい、nullチェックがそもそも過剰に感じるからas Itemしてエラーを消した、といった消極的なTypeScriptの活用をしている声は少なからず聞きます。筆者としてはこれは残念なことで、わざわざリスクを冒してまでnull混入の可能性を増やすべきではないと考えています。

とはいえ、複数のパラメータが出てくるときに逐一if文を書くようであれば、可読性が低下するというのはもっともな意見です。そこでTypeScriptのAPIのひとつであるAssertion Functionsを採用することをおすすめします。

Assertion Functionsを活用することで、こういったnullチェックのリスクを生まずに、TypeScriptの恩恵を最大限受けながら可読性高く処理を書くことができます。

assertExists()

Assertion Functionsに関しては、公式サイトのサンプルコードを別途紹介するより、いきなり筆者の業務で扱っている実例を紹介したほうがわかりやすいかもしれません。次のコードは、実際に業務で使っているコードそのままのassertExists()関数と、関連の関数exists()です。特に関数宣言の戻り型アノテーションに注目してください。

export function exists<T>(v: T | null | undefined): v is NonNullable<T> {
  return typeof v !== 'undefined' && v !== null;
}

export function assertExists<T>(
  v: T | null | undefined,
  target = ''
): asserts v is NonNullable<T> {
  if (!exists(v)) {
    throw new Error(`${target} should be specified`.trim());
  }
}

この例では、assertExists()の戻り型アノテーションasserts v is NonNullable<T>と、exists()の戻り型アノテーションv is NonNullable<T>が特徴です。このasserts v isv isとはなんでしょうか、みていきましょう。

この表記は、前者asserts v isをAssertion Signature、後者v isをType Predicate Signatureといいます。よく似ていますが導入時期が異なり、ドキュメントでも別々に記載されています。覚える際にはまとめて覚えてよいでしょう。

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions
https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates

効果としては、assertExists()に関しては「この関数assertExists()が例外を投げず完了できたならば、引数vを以後はis以降に指定された型とみなす」であり、exists()に関しては「このexists()booleanを返し、trueであるならば引数vis以降に指定された型とみなす」として振る舞います。assertExists()の挙動を確認するコードで書き表してみます。

const stringOrNull = ((): string | null => {
  if (Math.random() < 0.5) {
    return null;
  }
  return "hello";
})();

const otherVariable1 = stringOrNull;
//    ^? string | null

assertExists(stringOrNull);

const otherVariable2 = stringOrNull;
//    ^? string 推論の段階でもう `| null` ではない

exists()

同様にexists()も確認しましょう。

const stringOrNull = ((): string | null => {
  if (Math.random() < 0.5) {
    return null;
  }
  return "hello";
})();

const otherVariable1 = stringOrNull;
//    ^? string | null

if (exists(stringOrNull)) {
  const otherVariable2 = stringOrNull;
  //    ^? string
} else {
  const otherVariable3 = stringOrNull;
  //    ^? null
}

assertsExists()void | neverを戻り型とし、exists()booleanを戻り型とするため、このように若干コードに違いが現れています。分岐をせずに偽のときは即例外扱いにするような状況であればasserts v isを使うAssertion Functionsを定義すると使いやすいです。筆者の場合は先にAssertion Functionsから実装し、必要に応じてあとからType Predicate Signatureを扱う判定関数を実装する場合が多いです。

なお、Type Predicate Signature v is を扱う関数全体のことをUser-defined Type Guardと呼び、そのような関数を使ったり、typeof演算子instanceof演算子などを使って型を特定していく手法全体をNarrowingといいます。

コンポーネントのハンドラを短く書く

handleClickAddToCart()の例に話を戻します。先の例ではif文を使っていましたが、assertsExists()を採用することで行数を縮め、すっきりと書くことができました。

const handleClickAddToCart = useCallback(() => {
  assertsExists(item);
  addToCart(item);
}, [item]);

毎回条件分岐を書くのは面倒だ、可読性が下がる、もしそういった意見があるようであれば安全性と可読性の両方を高められるAssertion Functionsの検討をおすすめします。

嘘をつけてしまうため注意

Assertion Functionsは適切に扱うと強力な仕組みとなりますが、扱いを誤ると問題が起こるため、その点を把握しておく必要があります。次のコードでは、嘘のAssertion Functionsを定義しています。

function assertString(v: string | null): asserts v is string {
  if (false) {
    // 絶対に該当しない
    throw new Error();
  }
}

const stringOrNull = ((): string | null => {
  if (Math.random() < 0.5) {
    return null;
  }
  return "hello";
})();

const otherVariable1 = stringOrNull;
//    ^? string | null

assertString(stringOrNull);

const otherVariable2 = stringOrNull;
//    ^? string

console.log(otherVariable2); // たまに null がくる

この例ではassertString()がひどい実装になっており、| nullかどうかの検証をまったくせずに素通ししており、v is stringであることを無担保に認めています。その結果、変数otherVariable2は、stringのみ推論されているにも関わらず、たまにnullになるという事態になってしまいます。

このようにasserts v isは非常に強力な強制力を持ってしまうため、この関数自体に誤りがあると信頼性が大きく低下してしまいます。筆者が案件で扱う場合は、必ずこの関数自体の単体テストを書くようにしており、もしコードレビュー中に他者がテストを伴わないAssertion Functionsを提出した場合には、一律でrejectするようにしています。そもそもあらゆる実装に対してテストを書くべきというのはもっともではありますが、Assertion Functionsに関しては一段と厳しく扱っています。

Array.prototype.filter()でのちょっと便利な書き方

is vの形式で戻り型アノテーションを記述するUser-defined Type Guardは、Array.prototype.filter()との相性がとてもよいです。なにも考慮しないままのfilter()と、User-defined Type Guardを組み合わせたときのfilter()を比較してみましょう。

const arr: (string | null)[] = ["a", null, "c", "d", null];

const filtered1 = arr.filter((v) => v !== null);
//    ^? (string | null)[]

const filtered2 = arr.filter((v): v is string => v !== null);
//    ^? string[]

console.log(filtered1); // ["a", "c", "d"]
console.log(filtered2); // ["a", "c", "d"]

filter()に渡されるコールバックの実装が同じであることから、結果となる変数filtered1と変数filtered2の中身はどちらも同じですが、型推論の内容は異なります。User-defined Type Guardを伴わないfiltered1の例では(string | null)[]のままなことに対して、User-defined Type Guardとしてv is stringを記述したfiltered2の例では| nullの可能性が除外されstring[]という結果になっていることが特徴です。

このようにArray.prototype.filter()を使いながらNarrowingしていきたい場合もUser-defined Type Guardが有用です。このテクニックは業務でしばしば扱います。

明日は『Branded Typesを導入してみる』

本日はTypeScriptの強力な機構であるAssertion Functionsを紹介しました。TypeScriptの安全な世界の外にはまだまだ危険がいっぱいです。他社サービスからのレスポンスに含まれるJSON、データベースSDKが返す配列、一歩外に出ると型の付いていない世界が続きます。そんな世界も安全に渡り歩くため、コンパイルタイムとランタイムの両方から型安全を保っていく考え方が求められます。明日以降もAssertion Functionsを用いたテクニックをいくつか紹介していきます。それではまた。

Discussion

ログインするとコメントできます