この記事は何か
TypeScript でオブジェクトの存在しないプロパティにアクセスしたときに、undefined
の代わりにデフォルト値を返す関数を実装してみました。
ES2015 のProxy
オブジェクトを使って型安全に実装しています。
やりたいコード
const obj = { a: 1, b: 2 };
const defaultObj = withDefaultValue(obj, 0);
console.log(defaultObj.a); // 1
console.log(defaultObj.b); // 2
console.log(defaultObj.c); // 0
console.log(defaultObj.d); // 0
目的
- 型安全に
??
(Nullish coalescing 演算子)を撲滅したい
実装してみた
ES2015 で導入されたProxy
オブジェクトを使って実装しました。Proxy
を使うと、オブジェクトの基本的な操作をカスタマイズできます。
type RecordKey = string | number | symbol; // 型推論のための型
type OrString = string & {}; // 型推論のための型
export const withDefaultValue = <DefaultObject extends Record<RecordKey, unknown>, DefaultValue>(obj: DefaultObject, defaultValue: DefaultValue) => {
return new Proxy(obj, {
get(target, prop) {
return Reflect.get(target, prop) ?? defaultValue;
},
}) as DefaultObject & Record<Exclude<keyof DefaultObject, RecordKey> | OrString, DefaultValue>;
};
この実装では、次のことを行っています:
-
get
トラップを使って、プロパティアクセスをカスタマイズ -
Reflect.get
で元のオブジェクトからプロパティ値を取得 -
??
演算子で、値がnull
かundefined
の場合にデフォルト値を返す - 型定義で元のオブジェクトの型を保持しつつ、任意のキーに対してデフォルト値の型を返せるように
使い方いろいろ
基本的な使い方
まずは数値をデフォルト値にする例です:
const obj = { a: 1, b: 2 };
const defaultObj = withDefaultValue(obj, 0);
console.log(defaultObj.a); // 1(元のプロパティ値)
console.log(defaultObj.b); // 2(元のプロパティ値)
console.log(defaultObj.c); // 0(デフォルト値)
存在しないプロパティc
にアクセスすると、ちゃんとデフォルト値の0
が返ってきますね!
文字列をデフォルト値にする例
次は文字列をデフォルト値にしてみます:
const obj = { a: 1, b: 2 };
const defaultObj = withDefaultValue(obj, "default");
console.log(defaultObj.a); // 1
console.log(defaultObj.b); // 2
console.log(defaultObj.c); // "default"
console.log(defaultObj.d); // "default"
数値以外のデフォルト値も問題なく設定できますね。
オブジェクト内の値をデフォルト値として使用する例
as const
を使ってオブジェクトを定義している場合でも、デフォルト値として使え、かつ型推論もうまく機能します。
const obj2 = { a: "default", b: 2, c: 3, d: 4 } as const;
const defaultObj2 = withDefaultValue(obj2, obj2.a);
console.log(defaultObj2.a); // "default"
console.log(defaultObj2.b); // 2
console.log(defaultObj2.c); // 3
console.log(defaultObj2.d); // 4
console.log(defaultObj2.e); // "default"
if 文/switch 文との比較
従来の条件分岐と withDefaultValue を使った実装を比較してみましょう。例として、ユーザーロールに応じた権限チェックを考えてみます。
if 文による実装
const checkPermissionWithIf = (role: string): boolean => {
if (role === "admin") {
return true;
} else if (role === "manager") {
return true;
} else if (role === "editor") {
return true;
} else {
return false;
}
};
switch 文による実装
const checkPermissionWithSwitch = (role: string): boolean => {
switch (role) {
case "admin":
return true;
case "manager":
return true;
case "editor":
return true;
default:
return false;
}
};
withDefaultValue を使った実装
const permissions = {
admin: true,
manager: true,
editor: true,
} as const;
const permissionMap = withDefaultValue(permissions, false);
const checkPermissionWithProxy = (role: keyof typeof permissionMap): boolean =>
permissionMap[role];
比較結果
実行結果はどれも同じですが、withDefaultValue を使った実装には以下のメリットがあります:
- コードの簡潔さ: 条件分岐が減り、コードがすっきりします
- メンテナンス性: 権限の追加・削除が単にオブジェクトのプロパティを編集するだけで済みます
- 宣言的プログラミング: 「何をするか」を中心に記述できます
- 型安全性: TypeScript の型推論が効きやすくなります
- 再利用性: オブジェクトを型に利用できる(as const との相性がいい)
特に、条件が多い場合やデフォルト値を何度も使う場合に、withDefaultValue の利点が顕著になります。
エディターでの補完
TypeScript の型推論が効くので、エディターでの補完も効きます。
実装の解説
Proxy オブジェクトについて
Proxy
オブジェクトは、オブジェクトの基本的な操作をカスタマイズするための仕組みです。
const proxy = new Proxy(target, handler);
-
target
: 元のオブジェクト -
handler
: トラップを定義したオブジェクト
今回はget
トラップのみを使いましたが、他にもset
、has
、deleteProperty
など様々なトラップがあります。
Reflect オブジェクトについて
Reflect
は、Proxy と対になる組み込みオブジェクトで、Proxy ハンドラーメソッドと同じ名前と引数を持つメソッドを提供します。
// これよりも
const value = target[prop] ?? defaultValue;
// こちらのほうが安全です
const value = Reflect.get(target, prop) ?? defaultValue;
Reflect.get
を使うと、元のオブジェクトのプロパティアクセスをより安全に行えます。プロパティが Symbol だったり、getter だったりする場合に便利です。
Nullish coalescing 演算子 (??)
??
演算子は、左辺がnull
またはundefined
の場合に右辺の値を返す演算子です。
const value = someValue ?? defaultValue;
これは||
演算子と似ていますが、||
演算子は左辺が falsy (0
, ''
, false
, null
, undefined
, NaN
) のときに右辺を返すのに対して、??
演算子は左辺がnull
またはundefined
のときだけ右辺を返します。
今回のようなデフォルト値の実装では、0
や''
、false
などの値も有効な値として扱いたいので、??
演算子のほうがより範囲が適切です。
TypeScript の型定義について
型定義の部分を詳しく見てみましょう:
<DefaultObject extends Record<RecordKey, unknown>, DefaultValue>(obj: DefaultObject, defaultValue: DefaultValue) => {
// ...
} as DefaultObject & Record<Exclude<keyof DefaultObject, RecordKey> | OrString, DefaultValue>;
ここでは:
-
DefaultObject
は元のオブジェクトの型 -
DefaultValue
はデフォルト値の型 - 返り値の型は
DefaultObject & Record<Exclude<keyof DefaultObject, RecordKey> | OrString, DefaultValue>
この型定義により、元のオブジェクトのプロパティはその型を保持しつつ、存在しないプロパティにアクセスした場合はデフォルト値の型DefaultValue
として扱われます。これによって型安全なコードが書けるようになります。
やってみての感想
この実装を使うと、オブジェクトのプロパティにアクセスする際に毎回 obj.prop ?? defaultValue
のように書く必要がなくなり、コードがすっきりします。特に、同じデフォルト値を何度も使う場合には便利ですね!
TypeScript の型定義もしっかりしているので、型推論もうまく機能します。エディタの補完も効くので、実用的だと思います。
Proxy
オブジェクトはブラウザのサポートも良くなってきていますが、古いブラウザをサポートする必要がある場合は polyfill が必要かもしれません。
まとめ
3 行で要約すると:
- TypeScript で存在しないプロパティにアクセスしたときにデフォルト値を返す関数を実装した
-
Proxy
オブジェクトを使って、オブジェクトのプロパティアクセスをカスタマイズした - 型定義もしっかり行い、型安全なコードを実現した
みなさんもぜひ試してみてください!何か改善点や質問があればコメントください。
現場からは以上です