TypeScriptで型定義を拡張!ExpressなどNode.jsライブラリの型をカスタマイズする方法
生徒
「TypeScriptでExpressを使って開発しているのですが、Requestオブジェクトに自分専用のデータを追加したいんです。でも、型エラーが出てしまって進めません。ライブラリの型を書き換えることはできるのでしょうか?」
先生
「なるほど、それは実務でもよくある悩みですね!Node.jsのライブラリはあらかじめ型が決まっていますが、TypeScriptには『アンビエント宣言』や『インターフェースの結合』という機能があって、後から型を追加したりカスタマイズしたりすることができるんですよ。」
生徒
「後から型を合体させることができるんですか?なんだか難しそうですが、やり方を教えてください!」
先生
「大丈夫ですよ、仕組みがわかれば意外とシンプルです。それでは、ライブラリの型定義をカスタマイズする具体的な手順を見ていきましょう!」
1. 型定義ファイルとは?まずは基本を知ろう
TypeScriptを使い始めると、必ず出会うのが型定義ファイルというものです。これは、JavaScriptで作られたライブラリに対して、「この関数は数字を受け取りますよ」「この変数は文字列ですよ」というルールを後付けで説明するための説明書のような役割を果たします。拡張子が「.d.ts」となっているファイルがそれにあたります。
Node.jsで有名なExpress.jsなどは、もともとJavaScriptで書かれています。そのため、そのままではTypeScriptから使いにくいのですが、世界中の有志が作成したDefinitelyTyped(デフィニトリー・タイプド)という巨大な型定義のリポジトリがあるおかげで、私たちは快適に開発ができています。npm install @types/expressのようにインストールするものが、まさにその説明書です。
しかし、開発を進めていると「この説明書に、自分だけのルールを少しだけ付け加えたい!」という場面が出てきます。例えば、ログインしたユーザーの情報をExpressのreq(リクエスト)という箱の中に入れて持ち運びたいときなどです。デフォルトの説明書にはユーザー情報なんて書いてありませんから、TypeScriptは「そんなものはありません!」と怒ってしまうのです。これを解決するのが今回のテーマである型のカスタマイズです。
2. 型の結合という魔法の仕組み
TypeScriptには、同じ名前のInterface(インターフェース)を定義すると、それらが自動的に合体するという面白い性質があります。これをDeclaration Merging(宣言の併合)と呼びます。プログラミング未経験の方に例えると、料理のレシピ本に、自分でメモ書きを付け足すようなイメージです。
もともとあるレシピ(型定義)を捨てて新しく作り直すのではなく、既存のレシピに「これも追加で!」と一行書き加えるだけで、TypeScriptはその両方を読み取って一つの大きなレシピとして認識してくれます。この仕組みを使えば、ライブラリ本体のコードを一切汚さずに、自分のプロジェクト専用の型を作り出すことができるのです。
ただし、どこにでも書けば良いというわけではありません。TypeScriptが「あ、これはあそこのライブラリへの付け足しだな」と気付いてくれるように、特定の書き方をする必要があります。それが、declare globalや名前空間(namespace)を使った指定方法です。難しそうに聞こえますが、書き方のパターンは決まっているので安心してください。
3. ExpressのRequestにプロパティを追加してみよう
それでは、一番よく使われる例として、ExpressのRequestオブジェクトにuserIdという項目を追加する方法を解説します。通常、Expressの型定義ではreq.userIdという項目は存在しないため、そのまま書くとエラーになります。これを解消するために、プロジェクト内に型定義専用のファイルを作成します。
まず、プロジェクトのルートフォルダ(一番上の階層)にtypesというフォルダを作り、その中にexpress.d.tsという名前でファイルを作成します。そこに以下のようなコードを記述します。
// expressというモジュールの型を拡張することを宣言します
declare namespace Express {
// 既存のRequestインターフェースに新しい項目を合体させます
export interface Request {
userId?: string;
userRole?: 'admin' | 'user';
}
}
このコードを書くことで、TypeScriptは「ExpressのRequestには、もともとの機能に加えてuserIdとuserRoleという箱もあるんだな」と理解してくれます。?をつけているのは、値が入っていない可能性もあるからです。これを書いた後は、実際のプログラムの中でエラーが出なくなります。実際の使い方は以下のようになります。
import express, { Request, Response } from 'express';
const app = express();
app.get('/', (req: Request, res: Response) => {
// さきほど追加したuserIdに値を代入してもエラーになりません!
req.userId = "user_12345";
res.send("ユーザーIDを保存しました");
});
4. tsconfig.jsonの設定を確認しよう
型定義ファイルを作成しただけでは、TypeScriptがそのファイルを見つけられないことがあります。そこで必要になるのが、tsconfig.jsonという設定ファイルの調整です。これは、TypeScript全体の動作ルールを決める「設定画面」のようなものです。
パソコンの操作に慣れていない方でも、このファイルの特定の場所を書き換えるだけで大丈夫です。typeRootsという項目を探してみましょう。もし無ければ、compilerOptionsの中に自分で書き足します。ここには「型定義ファイルはどこにあるか」という場所を教えてあげる役割があります。
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"typeRoots": [
"./node_modules/@types",
"./types"
]
}
}
この設定の意味は、「標準的なライブラリの型が入っている場所(node_modules/@types)と、さっき自分で作った場所(./types)の両方を見てね」ということです。これを忘れると、せっかく書いたカスタマイズが反映されないので注意しましょう。設定を変更した後は、一度エディタ(VSCodeなど)を再起動するか、TypeScriptのプログラムを動かし直すと確実です。
5. ライブラリ以外の独自のグローバル型を作る
ライブラリの拡張だけでなく、プロジェクト全体でどこからでも使える「自分専用の共通ルール」を作りたいこともあります。例えば、システムの環境変数(APIの鍵やデータベースのURLなど)にどのような名前があるかを定義する場合です。これを定義しておかないと、process.env.API_KEYと書いたときに「そんな鍵あるの?」とTypeScriptが不安がってしまいます。
このようなときは、declare globalという書き方を使います。これは「このルールは世界中(プロジェクト全体)どこでも通用する共通ルールだよ」という意味です。これを設定しておくと、どこでプログラムを書いていても、自動的に入力候補が出てくるようになり、打ち間違いなどのミスを劇的に減らすことができます。
// 全体で共有する型を定義します
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
PORT: string;
NODE_ENV: 'development' | 'production';
}
}
}
// これで他のファイルで export しなくても型が効くようになります
export {};
最後にexport {}と書いているのは、このファイルを「モジュール」として認識させるためのTypeScriptのおまじないです。これがないと、ただのテキストファイルとして扱われてしまうことがあるため、忘れずに書いておきましょう。これで、環境変数の管理もバッチリ型安全になります。
6. 型のカスタマイズが必要になる場面とは
そもそも、なぜわざわざこんな難しいことをして型をカスタマイズする必要があるのでしょうか?それは、大規模な開発になればなるほど、「どこに何が入っているか」を確実に把握する必要があるからです。プログラミング初心者のうちは、頭の中だけでデータの流れを追うことができますが、アプリが大きくなるとそうはいきません。
例えば、チームで開発をしているときに、一人が勝手にreq.userDataという箱を作って使い始めたとします。別の人がそれを知らずにreq.userInfoという似たような名前の箱を作ってしまうかもしれません。型定義をしっかり行っていれば、入力した瞬間に「その名前は使えませんよ」とエラーで教えてくれます。これは、開発中の「うっかりミス」を未然に防ぐための、非常に強力なバリアになるのです。
また、カスタマイズをすることで、エディタの強力なサポート(オートコンプリート)が受けられるようになります。req.と打っただけで、自分が追加した項目がリストに出てくるのは非常に気持ちが良いものですし、タイピングのスピードも上がります。型のカスタマイズは、単にエラーを消すためだけでなく、自分たちが楽をするための工夫でもあるのです。
7. エラーが出たときの対処法とデバッグのコツ
もし型定義をカスタマイズしてもエラーが消えない場合は、いくつかのポイントをチェックしてみましょう。まず一番多いのは、ファイル名の打ち間違いやフォルダ構成のミスです。TypeScriptは場所を厳しくチェックするので、tsconfig.jsonで指定した場所と実際の場所が一致しているか、よく確認してください。
次に、ライブラリ自体のバージョンが新しくなりすぎて、型定義の書き方が変わっている可能性もあります。そのようなときは、無理に自分で解決しようとせず、一度anyという「なんでも許す魔法の型」を使って一時的に回避するのも一つの手です。もちろん、anyを使いすぎるとTypeScriptを使う意味がなくなってしまいますが、学習の段階では立ち止まりすぎないことも大切です。
最後に、実行結果を確認するツールも活用しましょう。プログラムが正しく動いているかどうかは、以下のようにコンソールに出力して確認するのが基本です。
// サーバーを起動してログを確認した時の例
[Server]: Server is running at http://localhost:3000
[Log]: Request received for user_12345
[Success]: Custom type is working perfectly!
このように、一つずつ順番に確認していけば、必ず解決できます。ライブラリの型を自由に操れるようになれば、あなたはもう初心者卒業と言っても過言ではありません。少しずつ慣れていき、自分だけの使いやすい開発環境を構築していきましょう。