`*.d.ts` ファイルをコミットする前に知ってほしい4つのこと

2023/01/15に公開約3,200字

(1) 実は *.ts でいいかもしれない

次のファイルを見てください。

export type Bookmark = {
  id: number;
  url: string;
  comment: string;
};

このファイルには型しか書いてありませんね。ということは、「型定義ファイル」として bookmark.d.ts という名前にするべきでしょうか。実はそうではなく、この場合は bookmark.ts とするべきです。

「型定義ファイル」とは、「どこか別の場所にある実装に型をつけるためのファイル」です。たとえば、以下のファイルは「どこか別の場所にある実装」に型をつけているから、 *.d.ts にするのは自然です。

declare module "my-awesome-library" {
  function square(x: number): number;
  export = square;
}

いっぽう、 type Bookmark は別のどこかにある *.js の型を与えているわけではないので、 *.ts でよいです。

このように本来 *.ts であるべきものを *.d.ts にしてしまうことには問題があります。代表的な問題として型エラーの抑制があります。多くのプロジェクトで "skipLibCheck": true が有効になっていますが、これは *.d.ts で発生した型エラーを無視するというオプションであるため、プロジェクト内の *.d.ts の間違いも同様に無視されてしまいます。

(2) interface Window をやめる

以下の型定義を見てください。

interface Window {
  globalCounter: number;
}

こうやって型をつけたあと、以下のようなコードを書くと型が通りません。

globalCounter++;

仕方ないので以下のようにコードを書き換えたりします。

window.globalCounter++;

よくわからないけど、これで型が通ったのでよかった。ってなってませんか?これは interface Window の使い方を間違えています。

interface Window は、 Window.prototype に定義されている機能、たとえば window.open などに対して使うものです。グローバル変数として使いたければ以下のように書きます。

// constやletではダメ
declare var globalCounter: number;

または、以下のように declare global で包む必要があるかもしれません。 (同じファイル内に exportimport がある場合が該当)

declare global {
  var globalCounter: number;
}

(3) やっぱり実は *.ts でいいかもしれない

実は *.ts にも declare を書くことができます。対応する実装が *.ts として書かれているのであれば、それに統合するほうが読みやすいかもしれません。

たとえば (このような実装を推奨するわけではありませんが) 以下のようにNumberを拡張したとします。

import {} from "...";

Number.prototype.max = function(...others: number[]) {
  return Math.max(Number(this), ...others);
};

この拡張部分の型定義は以下のように書けます。

interface Number {
  max(...others: number[]): number;
}

実は、これは元の定義ファイルの中に統合することができます。

import {} from "...";

Number.prototype.max = function(...others: number[]) {
  return Math.max(Number(this), ...others);
};

declare global {
  interface Number {
    max(...others: number[]): number;
  }
}

こうすれば型定義と実装がセットになるので使い勝手がよくなります。

(4) typeRootsやtypesはなるべくいじらない

*.d.ts をコミットしたのに、tscがそのファイルを参照してくれないために効果を発揮していないというトラブルはよくあります。

tsconfig.jsonの typeRootstypes オプションは確かに *.d.ts に関係するオプションですが、これらは別パッケージの *.d.ts の参照方法を決めるためのオプションなので、これらのオプションを変更して自パッケージのプロジェクト構成の問題を解決しようとするのはおすすめしません。

大事なことは その型定義ファイルをプロジェクトに含めさせる ことです。そのための主なアプローチは以下の2つです。

  1. プロジェクト内の他のファイルから明示的に参照する。
  2. プロジェクトマニフェストのファイル一覧に含める。

明示的に参照する方法は2つあります。ひとつは import を使うことです。これは型定義がモジュール形式になっているときの推奨の方法です。

// lib.js と lib.d.ts が両方存在する場合
import "./lib";
// lib.d.ts だけが存在する場合
import type {} from "./lib";

もし型定義がモジュールになっていない場合は、triple-slash directiveと呼ばれる代替記法を使うことができます。

/// <reference path="./lib" />

また、 *.d.ts がはじめからプロジェクトに含まれるように、 tsconfig.json のファイル一覧 (files オプションまたは include オプション) を見直すという手もあります。

詳しい説明

本記事は以前書いたTypeScriptのdeclareやinterface Windowを勘で書くのをやめる2022のうち、特に万人に知ってほしい内容を取り出してまとめたものです。

上記の記事は *.d.ts の仕組みを体系的に理解するためのより構造化された説明になっているので、余裕があればぜひそちらも参照してください。

私信

Twitterアカウントが凍結されてしまいました。しばらくはFediverse/Mastodonに籠もろうと思います。

Discussion

ログインするとコメントできます