📑

TypeScriptのdeclareやinterface Windowを勘で書くのをやめる2022

2022/01/01に公開

おことわり

個々の関数や変数に正しい型をつける話はしません。TypeScript HandbookのDeclarationの章などを読むことをおすすめします。

かわりに、本稿では関数や変数の型宣言をどこにどう置くべきかの指針を与えます。

モジュールとスクリプト

declareを正しく使うにはまずモジュールとスクリプトの区別を理解し、意識することが大切です。

ブラウザやNode.jsは外部からの指定でモジュールとスクリプトを区別しますが、TypeScriptでは原則としてファイルの内容でモジュールとスクリプトを区別します

  • import 宣言または export 宣言が1つ以上あればモジュール。
    • CommonJSモジュールの場合はTypeScript専用構文である import = 宣言、 export = 宣言を使う。
  • それ以外の場合はスクリプト。
// スクリプト (importもexportも持たないため)
const x = 42;
// モジュール (exportを持つため)
export const x = 42;
// モジュール (importを持つため)
import React from "react";
const x = 42;

これは型定義ファイルである *.d.ts でも同様です。

ファイルを強制的にモジュールと認識させるには、以下のように書きます。

// ファイルを強制的にモジュールと認識させる
export {};

名前空間とnamespace宣言

namespace宣言はTypeScriptの独自機能なこともあり、普通にTypeScriptを書いていて使う機会は多くありませんが、型定義ファイルを作るときにはnamespaceへの理解がしばしば必要になるため本稿でも説明することにします。なおそれなりに詳しく説明を盛っていますが、実際にはfunctionとのマージくらいまで理解しておけば十分かもしれません。

まず、複数の関連する値をまとめるためのオブジェクトを名前空間 (namespace) と呼ぶことがあります。これはTypeScriptに限った話ではなく、ECMAScriptでは import * as ns を名前空間インポートと呼ぶなどの用例があります。

さらにTypeScriptでは名前空間に値だけではなく型を紐づけることができます。TypeScriptにおいて名前空間を特別扱いしなければいけない大きな理由のひとつがこれです。

// モジュール名前空間に紐づいた値
export const x = 42;

// モジュール名前空間に紐づいた型
export type T = number;

モジュールは名前空間ですが、モジュールではない名前空間もあります。TypeScriptの独自構文であるnamespace宣言によっても名前空間が作られます。

// module Foo でもよい。
namespace Foo {
  // ES Modules互換のexport宣言が利用可能 (一部構文のみ)
  export const x = 42;
  export type T = number;
}
type U = Foo.T[];

// 名前空間からのインポートには専用構文がある
import V = Foo.T;

なお namespace Foomodule Foo とも書けますが、これは名前空間が「内部モジュール」と呼ばれていた頃の名残りのようです。現在はこの宣言で作られるものを「モジュール」と呼ぶのは一般的ではないので注意してください。

namespace宣言が作る名前空間はES Modulesのモジュールシステムと比べて以下の特徴があります。

  • ファイル内に名前空間を複数書ける。またnamespaceはネストさせることができる。
  • function宣言などとのマージができる。namespace同士もマージできる。
    • グローバルファイル内にnamespaceを置く場合、複数ファイル間で同一namespaceのマージができる。
  • 外側の名前空間に対してネストしたスコープを形成する。
  • 独立した export 宣言は書けない。

function宣言等とのマージ

function宣言やclass宣言とnamespaceをマージできます。実行時はfunction宣言がhoist (書かれた位置によらずスコープの先頭で定義) され、そこにnamespaceの各定義がマウントされます。

namespace parse {
  export const async = asyncParse;
  export type Options = { ... };
}
function parse() { ... };
function asyncParse() { ... };

このパターンはCommonJSモジュールを表現するためにしばしば用いられます。CommonJSでは関数をエクスポートして、そのプロパティを追加定義としてマウントすることが多いからです。

namespace parse {
  export const async = asyncParse;
  export type Options = { ... };
}
function parse() { ... };
function asyncParse() { ... };

// parse名前空間とparse関数がまとめてエクスポートされる
export = parse;

このように書いておくと、ES Modules側ではデフォルトインポートと名前空間インポートが同等に扱われます。 (allowSyntheticDefaultImports + esModuleInterop の場合) [1]

import React from "react";
import * as ReactNS from "react";

// どちらもOK
const Component1: React.FC = () => { ... };
const Component2: ReactNS.FC = () => { ... };

ネストしたスコープ

namespaceはネストしたスコープを形成します。特に、モジュールのインポートはnamespace宣言による名前空間の中では書けないため、一番外側に記述します。

// namespaceの中には書けない
import React from "react";

export namespace foo {
  export const Component: React.FC = () => null;
}

名前空間のマージとスコープ

同じ名前空間を2回以上宣言すると、マージが発生します。

// foo.x と foo.y が作られる
namespace foo {
  export const x = 42;
}
namespace foo {
  export const y = 42;
}

スコープは互いに独立なので、前のブロックで宣言された変数を参照することはできません。

namespace foo {
  const x = 42;
  export const y = x + 100;
}
// namespace foo {
//   export const z = x + 100; // error
// }
namespace foo {
  const x = 84;
  export const z = x + 100; // OK
}

しかし、exportされた変数は別のブロックのスコープから参照できます。

namespace foo {
  const x = 42;
  export const y = x;
}

namespace foo {
  const x = 100;
  export const z = x + y; // OK (yは foo.y として扱われる)
}

スクリプトの場合は、複数ファイル間でnamespace宣言を共有することもできます。 (たとえばTypeScriptコンパイラ自身はグローバルに ts 名前空間を置く形で書かれています この場合そのままでは実行順序が不定になるので、ブロック直下で別のブロックのexportを使う場合は /// <references ...> で依存関係を記述します

独立した export 宣言は書けない

アンビエントではないnamespace宣言内では「独立した export 宣言」は書けません。 (アンビエントについては後述)

ここでいう「独立した export 宣言」というのは以下のようなものを指します。

  • 独立した export 宣言 (export が宣言の不可分な一部である)
    • export { x as y };
    • export { x as y } from "mod";
    • export default 42;
    • export default function() { ... }
    • export default class { ... }
  • 独立していない export 宣言 (export を外しても宣言として成立する)
    • export const / export let / export var
    • export function / export class

別の言い方をすると、アンビエントでないnamespace宣言においてはエクスポート変数はローカル変数のサブセットである必要があります。この不変条件を壊すような構文は許されていないというわけです。

namespace foo {
  const x = 42;
  // error
  export { x as y };
}

また、 export default はアンビエントなnamespace宣言 (declare namespace) でも使えないようです。

アンビエント宣言 (declare) と .d.ts

JavaScriptコードを生成せず、型推論器にだけ情報を渡すのに使われるのが アンビエント宣言 (または環境宣言; ambient declaration) です。 declare のついた宣言がアンビエント宣言になります。 (declare なのに呼び名が「アンビエント」なのは不思議ですが、declareは「宣言する」という意味なので仕方なさそうです)

declare const x: number;
declare const y: unique symbol;
declare function f(): void;

アンビエント宣言には実装を与えることはできません。たとえば以下のようなものは実装にあたるため原則として書くことはできません。

  • 変数の初期化子
    • 例外として、数値リテラルやenum値による初期化などはそのまま書くことができる
  • 関数の本体
  • クラスフィールドの初期化子
  • コンストラクタ・メソッドの本体
  • 宣言以外の文

アンビエントコンテキスト

.d.ts 内ではアンビエント宣言のみを書くことができます (import, export, type, interfaceなどいくつか例外はあります)。これを アンビエントコンテキスト (または環境コンテキスト; ambient context) といいます。

// foo.d.ts
const x = 42; // declareがついていないのでエラー
console.log("Hello!"); // 宣言以外の文はエラー

declare module, declare namespace, declare class など他のdeclare内もアンビエントコンテキストとして扱われます。さらに、これらのアンビエントコンテキストでは自動的に declare が付与されます。いくつかのケースでは、二重にdeclareをつけること自体が禁止されています

export declare namespace foo {
    // 自動的にアンビエントになる (declareを二重につけることはできない)
    export const x: number;
}

アンビエントコンテキスト内では宣言以外の文を書くことはできません。

自動的にexportになる条件

アンビエントコンテキスト内の宣言は特定条件下で自動的にexportとして扱われます。その条件とは、所属する名前空間やモジュールが「独立した export 宣言」を持たないことです。ここでいう「独立した export 宣言」というのは以下のようなものを指します。 (再掲)

  • 独立した export 宣言 (export が宣言の不可分な一部である)
    • export { x as y };
    • export { x as y } from "mod";
    • export default 42;
    • export default function() { ... }
    • export default class { ... }
  • 独立していない export 宣言 (export を外しても宣言として成立する)
    • export const / export let / export var
    • export function / export class
namespace foo {
  // foo.x は外からは見えない
  const x = 42;
}
declare namespace bar {
  // bar.x は外から見える
  const x = 42;
}
declare namespace baz {
  // baz.x は外からは見えない
  const x = 42;
  export {};
}
declare namespace baz {
  // baz.x は外から見える
  const x = 42;
  export const y = 42;
}

このルールは *.d.ts がモジュールの場合のファイル直下の宣言にも適用されます。モジュールファイルとスクリプトファイルが区別される条件とは少し異なるので注意が必要です。

// foo1.d.ts
// x, y はどちらもエクスポートされる
var x: number;
export var y: number;
// foo2.d.ts
// yのみエクスポートされる
var x: number;
export var y: number;
export {};
// foo3.d.ts
// モジュールではない (window.x が定義される)
var x: number;

オーバーロード

関数のオーバーロードを宣言している場合は、オーバーロードの実装が書かれているfunction宣言はそれ全体が実装であり、アンビエント宣言にするときは丸ごと削除する必要があります。

// 実装として書く場合
function f(cb: () => void): void;
function f(): Promise<void>;
function f(cb?: () => void): Promise<void> | void {
    if (cb) cb();
    else return Promise.resolve();
}

// アンビエント宣言にすると3つから2つに減る
declare function f(cb: () => void): void;
declare function f(): Promise<void>;

アンビエントモジュールとモジュール拡張

module 宣言には以下の3種類があります。

  • namespace宣言の歴史的な別名 (module Foo {})
  • 通常のアンビエントモジュール宣言 (declare module "foo" {})
  • モジュール拡張宣言 (declare module "foo" {})

通常のアンビエントモジュール宣言とモジュール拡張宣言は見た目がほぼ同じなので紛らわしいですが別物です。この違いを意識することで混乱を避けることができます。

なお、通常のアンビエントモジュール宣言とモジュール拡張宣言はどちらもアンビエント宣言の形でのみ使用できます。 (= .d.ts 内で書くか、 declare をつけるか、別の declare の一部として書く必要がある)

通常のアンビエントモジュール

モジュール以外の場所module "foo" を宣言すると通常のアンビエントモジュール (ambient module) 宣言になります。アンビエントモジュール宣言を使うと、別の場所にあるモジュールに対して型定義を与えることができます。

declare module "square" {
  export default function square(x: number): number;
}

通常のアンビエントモジュールは 「誰かがこの名前でインポートを試みたら、この型定義を適用してください」というグローバルな宣言 です。そのため、

  • 通常のアンビエントモジュールでは相対パスを使うことはできません。
  • どこからインポートされたかによって異なる定義を出し分けることはできません。たとえば @types/foodeclare module "foo" { .. } の形で書かれたといます。 @types/foo のバージョン1とバージョン2が依存グラフに共存していたとして、 dependencies / devDependeices の記述にもとづいてうまく意図したバージョンの型定義が使われるとは限りません。 (後述する typeRoots の説明も参照のこと)
    • 実際にはDefinitelyTypedの型定義の多くは declare module "foo" を使わずにモジュールファイルとして書かれています。

また、通常のアンビエントモジュールではワイルドカードが使用可能です。よくある利用例は、Webpack等のバンドラで拡張子にもとづいてアセットをインポートできるようにしているときに、その型をTypeScriptに理解させるというものです。

// *.png という名前のインポートは常に文字列をデフォルトエクスポートしているものとして扱わせる。
// 実際に *.png というファイルがあるかどうかはTypeScriptは検査してくれないので注意。
declare module "*.png" {
  const url: string;
  export = url;
  // または、CommonJS対応が不要なら export { url as default }; でもよい
}

モジュール拡張

一方、モジュール内で module "foo" を宣言するとモジュール拡張 (module augmentation) という別の機能になります。正確には以下のどちらかの場合にモジュール拡張になります。

  • ファイルがモジュールで、その直下で declare module "foo" が宣言された場合
    • declare module "foo" はモジュール拡張になる。
  • ファイルがスクリプトで、その中に declare module "bar" があり、さらにその中で declare module "foo" が宣言された場合
    • declare module "foo" はモジュール拡張になる。
    • declare module "bar" は通常のアンビエントモジュール。

モジュール拡張は、指定した文字列をモジュールインポートと同様に解決した上で、その(既にある)モジュールの定義に継ぎ足す形で定義を追加するものです。そのため以下の特徴があります。

  • 別途型定義が与えられているモジュールに対してのみ有効。
    • ただし、 .d.ts 内でモジュール拡張を行った場合はなぜかエラーにならないようです。
  • 相対パスも利用できる。
  • ワイルドカードは利用できない。
export {};

// 上にexportがあるので、この宣言はモジュール拡張として扱われる。
// (すでにある "react" の型定義に継ぎ足す形で定義される)
declare module "react" {
  export type VVFC = null;
}

さらにモジュール拡張では、以下のような追加の制約があります。

  • 独立した export 宣言 (export {};export default ..., export = など) は書けない
  • import 宣言 (import = を含む) は書けない
    • かわりに外側のモジュールでインポートを行う。
  • モジュール拡張をさらにネストさせることはできない。

そのため、これらのエラーが出ていない範囲内では、全ての宣言が自動的にexportになります。

モジュール拡張は宣言のマージと組み合わせて使うことが想定されているため、新規宣言の追加をエラーにする仕組みがあったようです。意図的かは不明ですが、現在このチェックは機能していません。

自動的にexportになる条件

すでに説明したように、declare module内では特定条件下でexportが自動的に付与されます。詳しくは上のほうを読み返してください。

スコープの扱い

アンビエントモジュール宣言やモジュール拡張のスコープはnamespace宣言と似ています。

  • 親ブロックの定義をそのまま参照することができます。
  • 同じモジュールのexportされた定義を参照できます。
  • 同じモジュールのexportされていない定義は参照できません。
// user.ts

type SecretUserID = number;
export type CompanyID = number;

export class User {}
// aug.ts

// module augmentationにするためにimportを置いておく (なんでもよい)
import "./user";

type Privilege = "admin" | "user";

declare module "./user" {
  interface User {
    // 親ブロックの定義を参照できる
    privilege: Privilege;
    // 同じモジュールのexportされた定義を参照できる
    company_id: CompanyID;
    // 同じモジュールのexportされていない定義は参照できない
    // secret_id: SecretUserID; // error
  }
}

CommonJSに対するモジュール拡張

export = で定義されたモジュールに対するモジュール拡張は、対応するnamespace内に直接適用される(ことがある?)ようです。

import { Component } from "react";

// @types/react/index.d.ts (モジュール) の namespace React に直接適用される
declare module "react" {
  interface Component<P, S, SS> {
    foo: number;
  }
}

declare globalとglobalThisとinterface Window

グローバル変数になる宣言とならない宣言

スクリプト (モジュールではないファイル) で宣言したものは、グローバル変数になるものとならないものがあります。 (JavaScriptのランタイムの挙動と対応しています)

  • グローバル変数になるもの
    • var宣言
    • function宣言
    • namespace宣言 (値のエクスポートを1つ以上持つ場合)
    • enum宣言
      • 値は型チェック時にグローバル変数にならない?
    • type宣言、interface宣言
  • グローバル変数にならないもの
    • let宣言、const宣言
    • class宣言

declare global

モジュールベースで開発していてもグローバル変数を宣言したくなることはあります。スクリプトファイルを別に用意したくない事情として、たとえば以下のような状況が考えられます。

  • isolatedModules を指定しているため、スクリプトファイルを置くだけでエラーになってしまう。
  • import でインポートできるようにしたい。
    • スクリプトファイルは import でインポートできない。

そうでなくても、わざわざスクリプトファイルを別に用意するのは面倒です。こういう場合は declare global { ... } という特別な構文を使うことでスクリプトと同じ条件で型定義を与えることができます。 (他と同様、他の declare 内では単に global { ... } と書きます)

export function freshId() {
  window.counter ??= 0;
  return counter++;
}

declare global {
  // 誰が何と言おうともletやconstではなくvarを使う
  var counter: number;
}

スクリプトファイルと同様、 declare global 内で letconst を使っても意図通りにはなりません。eslintに怒られても無視して var を使いましょう。

globalThis

スクリプトファイルや declare global 内で定義されたもの (let/const/classを除く) はグローバル変数として扱われますが、これは globalThis という特別な名前空間にマウントする ことで実現されています。

globalThis は他の名前空間と同様、型もマウントすることができます。実はTypeScriptの標準型定義もこの globalThis 上にあります。

// globalThis をつけても参照できる
type JsonObj = globalThis.Record<string, unknown>;

interface Window

window にメンバを増やすために以下のように書いている事例がたまにあります。

interface Window {
  myGlobalVariable: number;
}

まず大前提として、これはスクリプトファイルか declare global 内でしか動作しません。 (さもなくば単に新しい Window というインターフェースが定義されるだけ)

その上で、これは以下のような結果になるため、意図したものとは異なる可能性が高いです。

  • window 以外のウインドウオブジェクト、たとえば window.frames[0]open() などにも同名のプロパティが存在することになる。
  • 直接 myGlobalVariable のように参照することができない。

繰り返しになりますが、グローバル変数を定義するには以下のように書けばよいです。 interface Window を書かずにこのようにすることをおすすめします。

// import/exportがある場合はdeclare global内に書く
declare global {
  // letやconstにはしない。letやconstにするとグローバル変数にならない
  var myGlobalVariable: number;
}

export ...;
// ファイルがimport/exportを持たない場合は直接declare varを書く (letやconstにはしない)
declare var myGlobalVariable: number;

@types/typestypeRoots

@types/ の使い道

npmの @types/ スコープ以下はDefinitelyTypedの型定義がpublishされています。 (DefinitelyTyped, npmのスコープ, node_modules などについては本稿では詳しく説明しないので、適宜他の資料で補ってください)

TypeScriptの型検査器は @types/ を特別扱いしますが、これには大きく2つの参照方法があります。

  • モジュールベース。importrequire によるもの。TypeScriptのモジュール解決ルールのフォールバックとして参照される。
  • 非モジュールベース。
    • /// <reference types=...> 指令による明示的な指定。
    • types / typeRoots による自動的なインクルード。

モジュール解決ルールのフォールバック

TypeScriptのモジュール解決ルール ("moduleResolution": "node" の場合) はNode.jsのモジュール解決ルールを模倣していますが、型定義を柔軟に解決するためにいくつかの変更が加えられています。

  • 拡張子 .d.ts.ts なども探索対象に含まれる。
  • Node.jsでは各祖先ディレクトリから node_modules 以下が探索されるが、TypeScriptではさらに各祖先ディレクトリから node_modules/@types 以下も探索される。
  • Node.jsではpackage.jsonの main フィールドに基づいて解決されるが、TypeScriptでは types フィールドが使われる。

このうち node_modules/@types を探索するルールがあるため、 import "react";node_modules/react 以下を探索したあと node_modules/@types/react の探索を試みます。ディレクトリのため代替パスのひとつである node_modules/@types/react/index.d.ts が探索され採用されるという寸法です。

なお、探索対象自体がscoped packageの場合 (例: import "@babel/core";) は、node_modules/@types 以下を探索するときにモジュール名のマングリング処理が発生します。そのため @types/@babel/core ではなく @types/babel__core が探索対象となります。

非モジュールベースのインクルード

トリプルスラッシュ指令のひとつである <reference types="..." />を使うことでもTypeScriptコンパイラに型定義を読み込ませることができます。

/// <reference types="node" />

これはモジュール以外の型定義が読み込まれるときの標準的なアプローチです (ただし後述するように多くの場合は自動で解決されます)。代表的なものに @types/node, @types/jest, @types/chrome などがあります。

reference types指令にはモジュールとは異なる解決ルールが用いられます。これは以下のようなものです。

  • primary lookupルール: typeRoots で指定されたディレクトリから順に探索する。
  • secondary lookupルール: primaryで見つからなかったら、モジュール解決と同様のルールで探索うる。

reference typesのprimary lookupで使われるベースディレクトリを指定するのが typeRoots オプションです。typeRootsのデフォルト値は、ベースディレクトリの各祖先ディレクトリに対する ./node_modules/@types です。

自動インクルード

`<reference types="..." /> は明示的に書かなくても以下のルールで自動で生成されます

  • types オプションの指定があるときは、それらに対応する自動インクルードが生成される。
  • types オプションの指定がないときは、各typeRootsのサブディレクトリを探索し、package.jsonにtypingsがあるものを全て列挙する。

宣言マージはどこまで遡って反映されるか

宣言マージは、元々あった宣言に対する副作用とみなすことができます。すると、これらがどういった順番で反映されるのかが気になるところです。

たとえば以下のシンプルな例では、 interface Foo に追加されたフィールドはそれより遡って反映されていることになります。

interface Foo {
  x: number;
}

const foo: keyof Foo = "y"; // OK

interface Foo {
  y: number;
}

では、TypeScriptファイルがより広範囲にわたって存在している場合はどうでしょうか。結論から言うと、全ての宣言マージの副作用が型チェック前に全体反映されるようです。これはTypeScriptが以下の手順でビルドを行っているからです。

  • 設定をパースする。このとき filesinclude, exclude オプションが展開され、初期ソースファイルの集合が決定される。
  • まず全てのソースファイルの一覧が含まれたProgramインスタンスを作成する。そのcreateProgram内では以下のような処理が行われている。
    • 設定から得られたソースファイル一覧を初期集合として使う。
    • libtypes で指定された型定義ファイルを追加する。 (types のデフォルト値については前述の記述を参照)
    • 初期ソースファイルをパースし、以下のような指令を辿ってソースファイルの集合を拡大する。
      • import, export from, require などのモジュールインクルード指令
      • /// <reference path="..." />/// <reference type="..." /> などの非モジュールベースのインクルード指令
    • 型検査器などを初期化する。このとき型検査自体は行われないが、宣言をシンボルに束縛する処理が全てのファイルに対して実行される
  • Programインスタンスに対してクエリを発行する。たとえば「把握している全てのファイルに対して型検査をし、エラーを報告し、 *.d.ts*.js を出力し、エラーに応じてexit statusをセットする」などの処理を行う。
    • 型検査はクエリ処理中に必要になった段階で行われる。 (たとえばLSPであるtsserverでは要求されたファイルを起点に型検査を行う)
      • モジュール拡張を含む全ての宣言があらかじめシンボルに束縛されているので、必要に応じて拡張された宣言も自動的に読み込まれる。

.d.ts.ts のどちらを書くべきか

.d.ts に書けるものは基本的に .ts にも書けるため、どちらで書くべきかという問題が発生します。

.d.ts.ts の取り扱いにはいくつかの細かい違いがありますが、特筆すべき特徴は以下の通りです。

  • .d.ts ファイル内のエラーは "skipLibCheck": true のとき抑制されてしまう。
    • 型定義の間違いに気付きにくくなるリスクがある。
  • 入力中の .d.ts はビルド後の出力に含まれない。
  • "isolatedModules": true のとき、スクリプトファイルを .ts として配置するとエラーになってしまうが .d.ts ならばエラーにならない。
    • アンビエントモジュール以外は declare global {} を使えばよいので .ts でも同じことは実現できる。

以上を踏まえると、次のような方針がいいのではないかと思います。

  • 原則として、自分が書くファイルは *.ts として配置する。
    • "isolatedModules": true 環境下でグローバル定義を書きたいときは declare global {} で囲んだ上で export {}; を足す。
  • 別ライブラリの型定義を足したいときは *.d.ts にする。
    • *.d.ts として書くときは、当該ファイルの編集中だけでもいいので "skipLibCheck": false にしておくとよい。

typestypeRoots はどう使うべきか

また、ここまでの議論を踏まえると typestypeRoots については以下のような使い方がよいでしょう。

  • typeRoots はいじらない。
    • "typeRoots": ["src/types"] などは想定された使い方ではないのでやめる。
      • 少なくとも "node_modules/@types" を含めておくべき。
    • ソースファイル中の型定義は "includes""files" で指定しておけばちゃんと含まれる。
  • types はいじってもよい。
    • 何をグローバル定義として読み込むか明示したいときは指定する。
    • たとえば、project referencesでライブラリ本体とテストを別projectで分ける構成にした場合、 @types/jest をテストコードでだけ読み込むような構成が書ける。
    • そこまで厳密にする必要がなければ、指定せずともうまく動作する。
      • typeRoots を下手にいじってるとうまく機能しなくなることがあるが、それは typeRoots をいじっているのが悪い

なお、yarn v2以降のPnPを使っているときはグローバル型定義の読み込みがうまく動かないことがあるようです。これは「TypeScriptがyarn PnP対応に消極的」という大きな問題の一部なので、なんか頑張ってください……

まとめ

  • モジュールとスクリプト
    • importやexportが1つでもあるときはモジュール、それ以外のときはスクリプトになる。モジュールかスクリプトかによって挙動が大きく違うので注意する必要がある。
      • 特に declare module "foo" はスクリプト内で書くと通常のアンビエントモジュールになるのに対し、モジュール内で書くとモジュール拡張になる。
  • 名前空間
    • TypeScriptではモジュールに加えて、namespace宣言によるものとglobalThisの3種類の名前空間がある。これらは細かい挙動は異なるものの、大まかには共通の特徴をもつ。 (宣言がマージできる、型をマウントできるなど)
  • アンビエント
    • declare をつけるとアンビエント宣言となり、コードが生成されなくなる。
    • アンビエントコンテキストではexportが省略できるケースがある。具体的には、独立したexport宣言を持たないアンビエントコンテキストではexportが自動的に付与される。
  • グローバル変数
    • スクリプトファイルや declare global では varlet/const で挙動が大きく異なる。グローバル変数を定義するには var を使う必要がある。
    • グローバル変数を足すために interface Window をいじるのは不正確なので避ける。
  • typestypeRoots
    • @types 以下のパッケージはモジュールベースの解決と非モジュールベースの解決で扱いが異なる。非モジュールベースの解決は @types/node などモジュール以外の定義を含むもので使われることがある。
    • typeRoots は非モジュールベースの解決ルールを変更する。
    • types は非モジュールベースでの型定義のインクルードをオプションで指示するもの。指定しなければ、インクルードできそうなものが全て自動的にインクルードされる。
    • tsconfigでソースファイルとして指定した型定義はいつでも有効なので、 typeRoots をいじる必要はない。 typeRoots をいじらなければ原則として types もいじる必要はないはず。ただし、厳密性のために types を指定するという選択肢はありえる。

更新履歴

  • 2022-01-01 あけましておめでとうございます公開。
  • 2022-01-01 年始公開のために書ききれなかった部分を足した。「宣言マージはどこまで遡って反映されるか」「.d.ts.ts のどちらを書くべきか」「typestypeRoots はどう使うべきか」の節を追加。一部加筆。
脚注
  1. 正確にはCommonJSモジュールをESMから読み込んだときのデフォルトインポートと名前空間インポートはほぼ同じもののdefaultプロパティの扱いが違うはずなのですが、TypeScriptの型検査ではそこまでは考慮せず同一視しているようです。この辺りの挙動はかなり複雑なので本稿では省略します。 ↩︎

Discussion