オブジェクトの key が存在しなければ、デフォルト値を返すオブジェクト



## この記事は何か

TypeScript でオブジェクトの存在しないプロパティにアクセスしたときに、`undefined`の代わりにデフォルト値を返す関数を実装してみました。
ES2015 の`Proxy`オブジェクトを使って型安全に実装しています。

## やりたいコード

```typescript
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`を使うと、オブジェクトの基本的な操作をカスタマイズできます。

```typescript:withDefaultValue.ts
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`の場合にデフォルト値を返す
- 型定義で元のオブジェクトの型を保持しつつ、任意のキーに対してデフォルト値の型を返せるように

## 使い方いろいろ

### 基本的な使い方

まずは数値をデフォルト値にする例です：

```typescript
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`が返ってきますね！ :santa:

### 文字列をデフォルト値にする例

次は文字列をデフォルト値にしてみます：

```typescript
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`を使ってオブジェクトを定義している場合でも、デフォルト値として使え、かつ型推論もうまく機能します。

```typescript
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 文による実装

```typescript
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 文による実装

```typescript
const checkPermissionWithSwitch = (role: string): boolean => {
  switch (role) {
    case "admin":
      return true;
    case "manager":
      return true;
    case "editor":
      return true;
    default:
      return false;
  }
};
```

### withDefaultValue を使った実装

```typescript
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 を使った実装には以下のメリットがあります：

1. **コードの簡潔さ**: 条件分岐が減り、コードがすっきりします
2. **メンテナンス性**: 権限の追加・削除が単にオブジェクトのプロパティを編集するだけで済みます
3. **宣言的プログラミング**: 「何をするか」を中心に記述できます
4. **型安全性**: TypeScript の型推論が効きやすくなります
5. **再利用性**: オブジェクトを型に利用できる(as const との相性がいい)

特に、条件が多い場合やデフォルト値を何度も使う場合に、withDefaultValue の利点が顕著になります。

## エディターでの補完

TypeScript の型推論が効くので、エディターでの補完も効きます。

![スクリーンショット 2025-03-31 15.27.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183059/94e8db0b-0c0a-47f8-a0d2-c58ad1008564.png)

## 実装の解説

### Proxy オブジェクトについて

`Proxy`オブジェクトは、オブジェクトの基本的な操作をカスタマイズするための仕組みです。

```javascript
const proxy = new Proxy(target, handler);
```

- `target`: 元のオブジェクト
- `handler`: トラップを定義したオブジェクト

今回は`get`トラップのみを使いましたが、他にも`set`、`has`、`deleteProperty`など様々なトラップがあります。

### Reflect オブジェクトについて

`Reflect`は、Proxy と対になる組み込みオブジェクトで、Proxy ハンドラーメソッドと同じ名前と引数を持つメソッドを提供します。

```javascript
// これよりも
const value = target[prop] ?? defaultValue;

// こちらのほうが安全です
const value = Reflect.get(target, prop) ?? defaultValue;
```

`Reflect.get`を使うと、元のオブジェクトのプロパティアクセスをより安全に行えます。プロパティが Symbol だったり、getter だったりする場合に便利です。

### Nullish coalescing 演算子 (??)

`??`演算子は、左辺が`null`または`undefined`の場合に右辺の値を返す演算子です。

```javascript
const value = someValue ?? defaultValue;
```

これは`||`演算子と似ていますが、`||`演算子は左辺が falsy (`0`, `''`, `false`, `null`, `undefined`, `NaN`) のときに右辺を返すのに対して、`??`演算子は左辺が`null`または`undefined`のときだけ右辺を返します。

今回のようなデフォルト値の実装では、`0`や`''`、`false`などの値も有効な値として扱いたいので、`??`演算子のほうがより範囲が適切です。

## TypeScript の型定義について

型定義の部分を詳しく見てみましょう：

```typescript
<DefaultObject extends Record<RecordKey, unknown>, DefaultValue>(obj: DefaultObject, defaultValue: DefaultValue) => {
  // ...
} as DefaultObject & Record<Exclude<keyof DefaultObject, RecordKey> | OrString, DefaultValue>;
```

ここでは：

1. `DefaultObject`は元のオブジェクトの型
2. `DefaultValue`はデフォルト値の型
3. 返り値の型は`DefaultObject & Record<Exclude<keyof DefaultObject, RecordKey> | OrString, DefaultValue>`

この型定義により、元のオブジェクトのプロパティはその型を保持しつつ、存在しないプロパティにアクセスした場合はデフォルト値の型`DefaultValue`として扱われます。これによって型安全なコードが書けるようになります。

## やってみての感想

この実装を使うと、オブジェクトのプロパティにアクセスする際に毎回 `obj.prop ?? defaultValue` のように書く必要がなくなり、コードがすっきりします。特に、同じデフォルト値を何度も使う場合には便利ですね！

TypeScript の型定義もしっかりしているので、型推論もうまく機能します。エディタの補完も効くので、実用的だと思います。

`Proxy`オブジェクトはブラウザのサポートも良くなってきていますが、古いブラウザをサポートする必要がある場合は polyfill が必要かもしれません。

## まとめ

3 行で要約すると：

1. TypeScript で存在しないプロパティにアクセスしたときにデフォルト値を返す関数を実装した
2. `Proxy`オブジェクトを使って、オブジェクトのプロパティアクセスをカスタマイズした
3. 型定義もしっかり行い、型安全なコードを実現した

みなさんもぜひ試してみてください！何か改善点や質問があればコメントください。

現場からは以上です:fish: