NonNullable<T>と実例 assertExists() / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の17日目です。昨日は『intrinsic
とは そしてUppercase<T>
』を紹介しました。
NonNullable<T>
TypeScriptでは、Null- and undefined-aware typesという機構があります。これはTypeScript 2.0にて--strictNullChecks
オプションと同時に実装され当時驚きを集めました。それまではstring
やnumber
といった基礎的な型は検証できても、undefined
やnull
のリスクについては従来のECMAScriptを書く場合と変わらなかったためです。4.9である現在はこのエピソードはただの昔話であり、TypeScriptでundefined
やnull
を区別することは当たり前となっています。
さて、Partial<T>
の回で紹介したなかでOptional指定や| undefined
は微妙な挙動差があると述べました。Optional指定を外すためのUtility TypesのひとつがRequired<T>
です。そして| undefined
や| null
を外すためのUtility Typesのひとつが今回紹介するNonNullable<T>
です。この型自体はTypeScript 2.8で追加されました。
さっそくサンプルコードから挙動を確認しましょう。
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
のようにT
にnull
やundefined
のみを渡し、取り除いた結果何も残らないようであればnever
が得られます。
T21
, T22
, T23
のようにオブジェクト内はネストしないことがわかります。また、Optional指定がなくなるわけではありません。T24
のように、Array<T>
の中の| null
に対しても操作されません。なのでこういった状況で一斉にOptional指定や| null
Union型を取り除きたいようであればRecursiveRequiredNonNullable<T>
のような型を自作することで解決できます。
null
の可能性を除去する実装
コンポーネント内でNullableな変数を扱うハンドラ
TypeScript 2.0で採用されたnull
, undefined
の区別はTypeScriptにとって非常に強力な機能追加でした。一方で少しでもnull
やundefined
の可能性があれば即座にエラー扱いにする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。本当にそうでしょうか?
そもそも、item
がnull
であるとき、handleClickAddToCart()
がバインドされたbutton
要素はレンダリングされていません。レンダリングされるのは<button disabled={true}>
の要素であるためです。ということはitem
がnull
であるにも関わらず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 is
やv is
とはなんでしょうか、みていきましょう。
この表記は、前者asserts v is
をAssertion Signature、後者v is
をType Predicate Signatureといいます。よく似ていますが導入時期が異なり、ドキュメントでも別々に記載されています。覚える際にはまとめて覚えてよいでしょう。
効果としては、assertExists()
に関しては「この関数assertExists()
が例外を投げず完了できたならば、引数v
を以後はis
以降に指定された型とみなす」であり、exists()
に関しては「このexists()
はboolean
を返し、true
であるならば引数v
をis
以降に指定された型とみなす」として振る舞います。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