TypeScript上級講座 #6 モジュールと.d.ts

ここまでの五編はすべて自分で書いた型をどう加工するかでした。今回はその先 — 外部から入ってくる型をどう受け入れ拡張するかがテーマです。

同じコードなのにあるモジュールは自動で型が取られ、あるライブラリはimportするだけで赤線が出る違いはどこから来るのか。グローバルオブジェクトに新しい属性を加えるには何を使うべきか。こういう質問の答えが宣言ファイルdeclareキーワードに入っています。

モジュールはどう型を持つようになるか #

TypeScriptがモジュールの型を知る経路は三つです。

  1. 直接.tsで作成 — 最も単純。同じプロジェクトのコード。
  2. パッケージの中に.d.tsファイルが同梱 — ライブラリが自前の宣言ファイルを提供する場合。
  3. @types/xxxパッケージ — DefinitelyTypedが別途提供するコミュニティ宣言ファイル。
@typesインストール — ライブラリに型が同梱されていないとき
npm install --save-dev @types/lodash

最近は人気のライブラリのほとんどが自前で.d.tsを同梱します。@types/...が必要なのは段々減る傾向です。それでも時折、自分で作らねばならないときがあります。

パッケージはどう型を公開するか — package.json #

package.jsontypes(またはtypings)フィールドが要点です。

ライブラリのpackage.json (例)
{
  "name": "my-lib",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

typesが指すファイルがモジュールの公開型インターフェースです。import { x } from 'my-lib'したとき、TypeScriptがそのファイルを読んでxの型を知ります。

最近のESMパッケージはexportsフィールドの中にtypesを置くのが標準になりました。

exportsの使用 (最近の標準)
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

ライブラリ作者でなければ詳しく知る必要はありませんが、型が取れないときpackage.jsonから見るのが出発点ということは覚えておいてください。

.d.tsファイル — 実装なしにシグネチャだけ #

拡張子.d.tsdeclarationの略です。実行可能なコードを入れず、型シグネチャだけを書くファイルです。

example.d.ts
declare function greet(name: string): string;
declare const VERSION: string;

declare namespace Utils {
  function delay(ms: number): Promise<void>;
}

declareキーワードは「この値/関数/モジュールがどこかに存在し、コンパイラはそのシグネチャだけ信じて使え」という意味です。実際の実装はJavaScript側にあると約束するんです。

型推論には影響がありますが、コンパイル結果のJavaScriptには何の痕跡も残りません。純粋に型情報だけを追加するファイルです。

外部ライブラリに型がないとき #

@typesもなく自前の型もないパッケージをimportすると、普通次のエラーが出ます。

よくあるエラー
Cannot find module 'some-old-lib' or its corresponding type declarations.

解決方法二つ。

1) 素早く防ぐ — モジュール全体をanyとして宣言 #

自分のプロジェクトのsrc/types/global.d.tsのようなファイルに次の一行を追加します。

src/types/global.d.ts
declare module 'some-old-lib';

こうするとそのモジュールのすべてのexportがanyになります。素早く防げますがそのライブラリの自動補完がなくなります。本当に応急処置です。

2) ちゃんと型を書く — 部分宣言ファイル #

ライブラリでよく使う関数だけ選んで型を直接書くのが次の段階です。

src/types/some-old-lib.d.ts
declare module 'some-old-lib' {
  export function compute(input: number): number;
  export function format(value: string, options?: { lowercase?: boolean }): string;
}

declare module '...'ブロックの中にimportしたとき使う関数のシグネチャを直接書きます。すべてのAPIを書く必要はなく、自分が使う部分だけ書けば十分です。時間が経つにつれて増やしていけば良いです。

このパターンはよく使われます。実務で外部ライブラリの型が不足しているとき、自分のプロジェクトの中に部分宣言ファイルを置いて統制するのがよくあるやり方です。

Module augmentation — 既存モジュールに追加する #

既存モジュールに型を加えることもできます。例えばnext-authのセッション型に私たちのアプリの追加フィールドを付けたいとき — パッケージをforkせず、自分のプロジェクトで拡張します。

src/types/next-auth.d.ts
import 'next-auth';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: 'admin' | 'user';
      name?: string | null;
      email?: string | null;
    };
  }
}

import 'next-auth'から書くのが要点です。モジュールimportがあるファイルでこそ、declare moduleがaugmentationとして動作します。importがないと新しいモジュールを宣言することと解釈されます。

interface Session宣言マージ(declaration merging)で元のSessionインターフェースに私たちが書いたフィールドが合わされます。この方式が動作する理由が基礎講座 #3で扱ったinterfaceの拡張可能性です。type aliasは宣言マージができないので、augmentationの用途にはinterfaceがほぼ常に正解です。

import.meta.env拡張 — Viteでよく出会うパターン #

Viteの環境変数の型を増やすときもaugmentationを使います。

src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_FEATURE_FLAGS: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

/// <reference types="vite/client" />Triple-slash directiveと呼ばれる特殊コメント。Viteが提供する基本型を引き出します。その上に私たちのプロジェクトの環境変数キーをインターフェースとして追加します。こうするとコードでimport.meta.env.VITE_API_URLの自動補完と型推論が正確になります。

Next.jsも同じ方式 #

Next.jsのprocess.envの自動補完を有効にしたいなら:

src/types/env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    readonly DATABASE_URL: string;
    readonly NEXT_PUBLIC_API_URL: string;
  }
}

namespace NodeJSはNode型定義に入っているグローバルnamespaceです。そこにProcessEnvインターフェースをaugmentすれば、process.envのキーが自動補完されます。

グローバル型の追加 — declare global #

モジュールの中からグローバル(windowのようなオブジェクト)に型を追加するなら、declare globalブロックを使います。

src/types/global.d.ts
export {};   // このファイルをモジュールにするために必要

declare global {
  interface Window {
    gtag: (...args: unknown[]) => void;
    myAppVersion: string;
  }
}

二つの要点。

  1. export {}を上に置けばこの.d.tsがモジュールファイルになります。そうしてこそdeclare globalが意味を持ちます。その一行が抜けるとファイル全体がグローバルスクリプトと解釈されてdeclare global自体が不要になります。
  2. Windowはlib.dom.d.tsのインターフェース — interfaceなのでaugmentation可能。window.gtag(...)のような呼び出しがTypeScriptで自動補完されます。

globalThisprocess.env、または自作のグローバルオブジェクトに型を追加するときも、すべて同じパターンです。

Triple-slash directive — 古い方式の名残 #

/// <reference path="..." />/// <reference types="..." />/// <reference lib="..." />のような特殊コメントを古いコードで時々見ます。ESM標準が定着した最近はほぼ使いませんが、一つのケースではまだよくあります — 宣言ファイルが他の宣言ファイルを引っ張ってくるとき。

上で見たvite-env.d.ts/// <reference types="vite/client" />がその例です。Viteの基本環境型をimport無しで引き出してaugmentする場面です。

直接書くことはほぼなく、こんな行に出会えば「宣言ファイルが他の宣言ファイルに依存しているんだな」程度に読めば十分です。

tsconfig.jsonのtypeRoots / types #

自分のプロジェクトがどんな宣言ファイルを自動で取り込むかを決めるオプションが二つあります。

tsconfig.json (関連抜粋)
{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./src/types"],
    "types": ["node", "vite/client"]
  }
}
  • typeRoots — 自動で型定義を探すフォルダ。デフォルト値は./node_modules/@types。私たちのプロジェクトの追加フォルダ(src/types)を加えたいときに明示。
  • typestypeRootsの中のパッケージのうちこのリストだけ自動で含む。明示しなければtypeRoots内のすべてのパッケージが自動で取り込まれます。

types敢えて埋めないでください。空けておけば必要なものが自動で入ります。明示するときは普通「このプロジェクトではReact DOMの型を意図的に外したい」のような特殊な状況です。

よくあるミス — typesに一行書いた瞬間すべて止まる #

これを書くとReactの型が消えます
{
  "compilerOptions": {
    "types": ["node"]    // ⚠️ React、vite/clientなどがすべて抜ける
  }
}

typesが空配列でも定義された瞬間、そのリストだけ自動で含まれます。「nodeだけ追加して残りはそのまま」が意図だったなら、実はtypes自体を書かないのが正解です。

現実的によく出会う場面 #

この記事で扱った道具のうち、普通仕事をしていて出会う場面は限られています。

  • 外部ライブラリに型がないとき → declare module 'pkg' { ... }で部分宣言
  • next-auth/Vite/Next.jsの環境型拡張 → augmentationパターン
  • window/globalに属性追加 → declare global { interface Window { ... } }
  • 自作ライブラリを作るとき → package.jsontypesフィールド + .d.tsビルド出力

ライブラリを直接作らない一般のアプリ開発者なら、上の四つのケースでパターンが分かる程度で実用的です。

まとめ #

今回整理した内容:

  • モジュール型の出処 — 直接作成 / パッケージ同梱 / @types/...
  • .d.ts — 実装なしにシグネチャだけ。declareキーワード
  • 型のないライブラリを素早く防ぐ — declare module 'pkg';
  • 部分宣言 — 使う関数だけシグネチャを書く
  • module augmentation — import 'pkg'; declare module 'pkg' { ... }
  • グローバルaugmentation — export {}; declare global { ... }
  • tsconfig.jsontypeRoots / typesの意味と落とし穴

次の記事(#7 実戦パターンとアンチパターン)ではシリーズ最後として、良い型と過剰な型を分ける基準 — any vs unknown vs neveras constsatisfies、そしてよく陥るアンチパターンを整理します。

X