TypeScriptのOmitを使用した既存の型の拡張について備忘録

## 環境情報

|項目|内容|
|---|---|
|エディタ|VSCode 1.92.1|
|JSランタイム|Bun [1.1.21](https://hub.docker.com/layers/oven/bun/1.1.21-slim/images/sha256-bbbe911897d0e436deeae32ca215ae10a0cd9835a45ad8924ffa851edf79708b?context=explore)|
|openaiパッケージ|[4.52.7](https://www.npmjs.com/package/openai/v/4.52.7)|

## 例
OpenAIの各種APIをJSランタイム用にラップしたライブラリ([openai](https://www.npmjs.com/package/openai)) の中に含まれる型として以下のものがありました。
https://github.com/openai/openai-node/blob/master/src/resources/beta/threads/threads.ts
```typescript:resources/beta/threads/threads.ts (抜粋)
/**
 * Represents a thread that contains
 * [messages](https://platform.openai.com/docs/api-reference/messages).
 */
export interface Thread {
  /**
   * The identifier, which can be referenced in API endpoints.
   */
  id: string;

  /**
   * The Unix timestamp (in seconds) for when the thread was created.
   */
  created_at: number;

  /**
   * Set of 16 key-value pairs that can be attached to an object. This can be useful
   * for storing additional information about the object in a structured format. Keys
   * can be a maximum of 64 characters long and values can be a maxium of 512
   * characters long.
   */
  metadata: unknown | null;

  /**
   * The object type, which is always `thread`.
   */
  object: 'thread';

  /**
   * A set of resources that are made available to the assistant's tools in this
   * thread. The resources are specific to the type of tool. For example, the
   * `code_interpreter` tool requires a list of file IDs, while the `file_search`
   * tool requires a list of vector store IDs.
   */
  tool_resources: Thread.ToolResources | null;
}
```

このThreadという型はopenaiパッケージのスレッド作成(`openai.beta.threads.create`)を実行したときの戻り値の型です。
JSDocに英語で書いてあるとおりmetadataには任意のKeyValueペアのデータを最大16個まで格納できるとありますが、`unknown | null`型となっているためアプリケーションで使用する際に固有の型ガードを作成しないとmetadata内のプロパティにアクセスする際、型に関するアラートが上がってしまいます。

```typescript:例えばこんな感じの型ガード
interface ThreadMetadata {
  hoge: string;
  fuga: string;
}

function isThreadMetadata(metadata: unknown): metadata is ThreadMetadata {
  return (
    typeof metadata === 'object' &&
    metadata !== null &&
    'hoge' in metadata &&
    typeof (metadata as ThreadMetadata).hoge === 'string' &&
    'fuga' in metadata &&
    typeof (metadata as ThreadMetadata).fuga === 'string'
  );
}
```

### 型ガードを使用しないとどうなるか

以下はNuxt3のserverディレクトリ以下に作成したAPIを使用したスレッド作成のサンプルです
```typescript:server/api/example.post.ts
import OpenAI from 'openai';

export default defineEventHandler(async (event) => {
  const openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });

  const thread = await openai.beta.threads.create({
    metadata: {
      hoge: 'hoge',
      fuga: 'fuga',
    },
  });
});
```

metadataがunknown型のためhogeというプロパティにアクセスしようとするとアラートが上がります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/17ff1edc-f557-4720-ed9b-75b88e7a1929.png)

### 型ガード関数を使用した場合

先ほどのコードに型ガード関数を使用したものが以下になります。

```diff_typescript:server/api/example.post.ts (修正)
import OpenAI from 'openai';

+interface ThreadMetadata {
+  hoge: string;
+  fuga: string;
+}
+
+function isThreadMetadata(metadata: unknown): metadata is ThreadMetadata {
+  return (
+    typeof metadata === 'object'
+    && metadata !== null
+    && 'hoge' in metadata
+    && typeof (metadata as ThreadMetadata).hoge === 'string'
+    && 'fuga' in metadata
+    && typeof (metadata as ThreadMetadata).fuga === 'string'
+  );
+}

export default defineEventHandler(async (event) => {
  const openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });

  const thread = await openai.beta.threads.create({
    metadata: {
      hoge: 'hoge',
      fuga: 'fuga',
    },
  });

-  console.log(thread.metadata.hoge);
+  if (isThreadMetadata(thread.metadata)) {
+    console.log(thread.metadata.hoge);
+  }
});
```

先ほどと違い型ガード関数を通した後のコード内でmetadata内のhogeというプロパティについてstring型であることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/d0ebe382-0ab3-61e6-ff06-35a680451ed0.png)

## 型ガード関数を使わない解決方法
型ガードを使うことでアラートを解決できるのであればそれで良いとも言えますが今回作成したアプリケーションの中ではスレッド作成の際、常に固有のメタデータを付加したやり取りを行っていました。そのため戻り値もまた同じように固有のメタデータが必ず付いた状態で返ってきます。
また、型ガード関数の実体はjavascriptの関数のため、ts⇒jsのトランスパイル時にも失われることなくJSのランタイムに乗っかって実行されます。ほとんど無視できるものではありますが、型ガード関数が「ある場合」と「ない場合」を考えた場合、当然型ガード関数を実行する分だけオーバーヘッドが発生するとも言えるのかなと思います。

<details>
<summary>一応ビルドして確認</summary>

`nuxi build` 実行後に `.output/server` ディレクトリ内を探索したところ `server/api/example.post.ts` を元に `.output/server/chunks/routes/api/example.post.mjs` というファイルが生成されていました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/f570cdbb-ed7a-2fdc-74f6-2dacd4a1a1e7.png)

コードを見てわかる通り `isThreadMetadata` は関数としてコード内に残っているので当APIが呼ばれるたびにこの実行されるであろうことがわかります。
</details>

---
ということで既存のThread型をベースとしつつ、metadataに固有の型を加えた新しい型を作ることにしました。
具体的には以下のような流れ。

1. `unknown | null`型のmetadataをOmit型を使用して省く（本題）
2. Threadからmetadataを省いた型を継承した新しい型の中で任意のmetadata型を設定

なお、openaiパッケージが提供するスレッド作成の関数は型引数をサポートしていません。
そのため、関数の実行結果のThread型をasを使用してExtendThread型に上書きすることで型の解決を図ります。

```diff_typescript:server/api/example.post.ts
import OpenAI from 'openai';
+import type { Thread } from 'openai/resources/beta/threads/index';

interface ThreadMetadata {
  hoge: string;
  fuga: string;
}

+interface ExtendThread extends Omit<Thread, 'metadata'> {
+  metadata: ThreadMetadata;
+}

export default defineEventHandler(async (event) => {
  const openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });

  const thread = await openai.beta.threads.create({
    metadata: {
      hoge: 'hoge',
      fuga: 'fuga',
    },
+  }) as ExtendThread;

  console.log(thread.metadata.hoge);
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/c3518d87-063c-9322-232a-14a4fcf16ce5.png)

metadataのhogeについてstringであるという補間が効きました。

## 備考
TypeScriptについて独学のため誤った箇所があるかもしれません。
もし認識が誤っている箇所ありましたらコメントでご教示いただけると嬉しいです。:bow:

## 参考
https://typescriptbook.jp/reference/type-reuse/utility-types/omit

https://www.npmjs.com/package/openai