🏷️

TypeScriptのJSDocサポートでできること、できないこと

47 min read

TypeScriptの主要な入力ファイルは .ts, .tsx, .mts, .cts ですが、JavaScriptファイル (.js, .jsx, .mjs, .cjs) も読み込んで処理することができます。JSDocによる型アノテーションを認識するため、生のJavaScriptでもそれなりに型をつけることができます。

本稿ではタイトル通り、TypeScriptのJSDocサポートでできることとできないこと (.ts でしかできないこと) をまとめます。

おことわり

  • 本記事はTypeScript 4.4時点での実装状況に基づいています。なるべくソースコード中の関係する箇所を参照するようにしたので、今後の変更はご自分で検証してください。 (TypeScript Playgroundで試すだけでも有用です JavaScriptモードで開始できるリンク)
  • JSDocの機能一覧・TypeScriptの独自機能一覧は、筆者が実装中を丹念に探索して列挙したものです。少なくとも公式ドキュメンテーションよりも網羅性が高くなるようにつとめていますが、100%の網羅性を保証するものではありません。同じ理由で、「JSDocでは実現できない」と記載しているものについても見落としがある可能性はあります。

総評

TypeScriptコンパイラ (tsc) はTypeScriptファイル (.ts) だけではなくJavaScriptファイル (.js) を入力にとることも可能で、設定次第では型検査を行うこともできます。しかしTypeScriptとJavaScriptで同じ機能が使えるわけではありません。TypeScript 4.4時点で、TypeScript側にのみある機能として以下があります。

  • 書きやすい型アノテーション
  • TypeScript独自機能 (namespace, enum, constructor parameter modifiers)
  • JSDocでは書けない各種アノテーション
    • 関数のオーバーロード宣言
    • 関数呼び出し、コンストラクタ呼び出し、タグ付きテンプレートリテラル、JSXの型実引数指定
    • interface宣言
    • 型に対するimport/export宣言 (型と値がセットの場合はJavaScriptでも可能)
    • アンビエント宣言 (クラスフィールド、変数、モジュール) およびモジュール拡張
    • クラスフィールドの一部アノテーション (optional field, フィールドの存在表明, declare, abstract)
    • クラスのindex signature
    • abstract class
    • as const
    • non-null表明
    • 型仮引数 (ジェネリクス引数) のデフォルト値
  • 推論できなかったジェネリクス引数のデフォルトが unknown になる
    • JavaScriptモードでは any になってしまう。

JavaScript側だけの機能として以下があります。

  • JSDoc互換の型アノテーション (TypeScriptモードでは無効)
    • 総じてTypeScriptほど強力ではなく、ドキュメント目的を兼ねるのでなければ記述量が増えるデメリットがある
    • あくまで(本来の)JSDocの表現力の範疇で推論のヒントとするように設計されているという印象
  • 型推論における、より積極的なヒューリスティックス
    • 名前空間パターンの推論
    • コンストラクタ関数の推論
    • this代入によるクラスフィールドの推論
    • プロトタイプ代入によるクラスフィールドの推論
    • CommonJS require/exports推論
  • <> の曖昧性があるときにJavaScriptの規格に準拠してパースされる

このようにして見てみると、tscのJavaScript対応は単なるTypeScript対応の下位互換ではなく、レガシーコード対応により特化しているようにも思えます。こういった傾向も踏まえた上で、個々の能力差も参考にしながら言語を選択するといいのではないかと思います。

TypeScriptコンパイラがサポートするファイルタイプ

TypeScript 4.4時点で、TypeScriptコンパイラは .ts, .tsx, .d.ts, .js, .jsx, .json の6個の拡張子を認識します

TypeScript 4.5時点で、TypeScriptコンパイラは .ts, .tsx, .mts, .cts, .d.ts, .d.mts, .d.cts, .js, .jsx, .mjs, .cjs, .json の12個の拡張子を認識します

デフォルト ESM CJS JSX JSX+ESM JSX+CJS
TypeScript .ts .mts .cts .tsx
型定義 .d.ts .d.mts .d.cts
JavaScript .js .mjs .mts .jsx
JSON .json

このうちJavaScriptに分類されるファイルは allowJs が有効なときだけ探索対象に含まれます。 (allowJscheckJs 指定時にデフォルトで有効化されます)

TypeScriptは途中で型エラーや不整合があっても構わず型チェックを進められるように実装されています。途中で得られた型エラーは必ず報告されるわけではなく、ファイルタイプごとのデフォルトは以下のようになっています。

TypeScript, JavaScript の場合は // @ts-check / // @ts-nocheck マジックコメントによって上記のデフォルト挙動を上書きすることができます

TypeScriptのJSDocサポートについて

JavaScriptファイル内ではTypeScript特有の構文は使えないかわりにJSDoc互換のコメントで型アノテーション・型定義を与えることができます。

// @ts-check

/**
 * @param x {number}
 * @return {number}
 */
export function square(x) {
  return x * x;
}

TypeScriptファイル内ではJSDocコメントは無視されますが、IDEの機能 (tsserverの機能) でJSDocコメントの型情報をTypeScriptの型アノテーションに変換することができます。

JSDoc内で型を指定するときはTypeScriptの構文がそのまま使えますが、それに加えてJSDoc互換の記法がいくつかサポートされています。これについては後述します。

なお、本来JSDocはドキュメンテーション生成ツールの名称ですが、ここではTypeScriptのJSDoc互換コメントの処理のことをJSDocと呼んでいます。この2つは本来ならば区別されべきものです。 (たとえば#14377のコメントを参照)

JSDocタグ一覧

型システムに関係のあるタグとして以下が認識されます

JSDoc 用途
@type {T}
@type T
型アノテーション
@this {T}
@this T
this 引数型アノテーション
@enum {T}
@enum T
Closure互換enum
@param {T} Name
@param Name {T}
(別名: @arg, @argument)
引数型アノテーション
@param {T} [Name]
@param [Name] {T}
(別名: @arg, @argument)
オプショナル引数型アノテーション
@property {T} Name
@property Name {T}
(別名: @prop)
@typedef のプロパティ宣言
@property {T} [Name]
@property [Name] {T}
(別名: @prop)
@typedef のオプショナルプロパティ宣言
@returns {T}
(別名: @return)
戻り値型アノテーション
@returns
(別名: @return)
型がない場合は特別な効果はない
@typedef {T} Name 型エイリアス (単独)
@typedef {Object} Name
@typedef {object} Name
@typedef {T[]} Name
型エイリアス (プロパティ宣言が後続可能)
@typedef Name 型エイリアス (プロパティ宣言が後続する)
@callback Name 関数型エイリアス (@param/@returns が後続する)
@template T0,T1,T2 ジェネリクス仮引数
@template {T} T0,T1,T2 ジェネリクス仮引数、制約つき
@implements {Iface<T>}
@implements Iface<T>
クラスのimplements表明
@extends {Klass<T>}
@extends Klass<T>
(別名: @augments)
クラスのextendsのジェネリクス引数指定
@constructor
(別名: @class)
コンストラクタ関数であることの表明
@public クラスメンバーの可視性 (public)
@private クラスメンバーの可視性 (private)
@protected クラスメンバーの可視性 (protected)
@readonly クラスフィールド等の読み取り専用表明
@override クラスメンバーのオーバーライド表明

型システムに関係ないタグとして以下があります。

  • @deprecated
    • IDEによる非推奨表示 (取消線で修飾するなど) のために使われる。
  • @author
    • <> で囲うと @ がパースされなくなるという特殊ルールのために実装されている。
  • @see
    • 他の定義へのリンクが解釈される。
  • @link
    • 他のタグの中で {@link ...} のようにして使う。
    • 他の定義へのリンクが解釈される。
  • それ以外のタグ (@abstract, @event, @async など)
    • このような形になっていればパースだけ行われる。

以下の指令はJSDocのようにも見えますが、実際にはdoc-comment形式 (/** ... */) でなくても受理されます。

  • @ts-check, @ts-nocheck (1行コメント形式のみ有効)
    • そのファイルの型エラーを出力するかどうかの制御。
  • @ts-ignore, @ts-expect-error
    • 次の行の型エラーを無視する。
  • @jsx, @jsxFrag, @jsxImportSource, @jsxRuntime (複数行コメント形式のみ有効)
    • React以外のカスタムJSX実装を使うときに指定する。
  • @internal
    • 指定した定義を .d.ts に含めない。 (--stripInternal 指定時に有効)

TypeScriptの独自機能

TypeScriptは基本的にはJavaScriptのランタイム機能に型定義と型アノテーションだけを乗せるような設計になっていますが、いくつかJavaScript側には存在しない機能があります。

namespace

namespace または module を使うことで名前空間を定義できます。

// module util でもよい
namespace util {
  export function square(x: number) {
    return x * x;
  }
}

JavaScriptのランタイム上は、名前空間はプレーンなオブジェクトの入った変数として扱われます。同名のnamespaceを複数回書くとマージされます。

TypeScript的には名前空間はファイルに紐付かない点以外はモジュールと同じように扱われます。名前空間内にはinterfaceやtypeなど型システム上にしか存在しない実体も入れることができます。

namespace util {
  export type Status = "opened" | "closed";
  export let status: Status = "opened";
}

名前空間からexportされた値や型は、import ... = 構文 (Legacy importの一種) でインポートできます。

namespace util {
  export type Status = "opened" | "closed";
  export let status: Status = "opened";
}

namespace foo {
  import StatusType = util.Status;
  import status = util.status;
  console.log(status);
}

enum

Cのenumと同様の機能です。デフォルトでは連番で値が振られます。

enum Foo {
  HTML,
  JS,
  JSON,
  PNG
}

ランタイム上は以下のようなオブジェクトになります。 (マージ処理は省略)

var Foo = {
  HTML: 0,
  JS: 1,
  JSON: 2,
  PNG: 3,
  0: "HTML",
  1: "JS",
  2: "JSON",
  3: "PNG",
};

またenumは同名の型定義をともないます。おおよそ以下と同等です。

type Foo = 0 | 1 | 2 | 3; // より緩くnumberとして扱われることもある

JSDocの @enum はClosure Compiler互換のために存在し、TypeScriptのenumとは挙動が異なります。

const enum

const enumenum とよく似ていますが、enum objectが定義されません。必ず Foo.HTML のようにenumの値を直接指す必要があります。

const enum Foo {
  HTML,
  JS,
  JSON,
  PNG
}
// console.log(Foo); // error
console.log(Foo.HTML); // 0

const enumはコンパイル時に Foo.HTML のような参照が定数値に置換されます。しかし、これを行うには別モジュールの型情報に基づいた置換が必要になるため、 --isolatedModules がオンのときは単なるenumとしてコンパイルされます。Babel (7.15.0以降) も同様です。

コンストラクタのパラメタ修飾子

コンストラクタ引数にpublic/protected/private/readonly/overrideなどの修飾子をつけることができます。修飾子が1つ以上ついている場合は同名のクラスフィールドが自動的に作られ、当該引数で初期化されます。

class PackageVersion {
  constructor(public readonly pkgname: string, public readonly version: string) {}
}

これは以下と同等です。

class PackageVersion {
  public readonly pkgname: string;
  public readonly version: string;
  constructor(pkgname: string, version: string) {
      this.pkgname = pkgname;
      this.version = version;
  }
}

レガシーデコレーター

メソッドなどの宣言に @something のような記法を前置することで挙動を変えたり情報を付与したりする仕組みです。将来的にECMAScriptに入る可能性がありますが、現在のTypeScriptの実装は古い提案に基づくため事実上の独自機能と言えます。詳しくは@petamoriken氏のESNext Stage 2 Decorators の変遷と最新仕様を参照してください。

import / export の消去

ここまで紹介した「独自機能」とは毛色が違いますが、TypeScriptファイルに対して行われる追加の変換として「import / exportの消去」があります。たとえば以下の例を考えます。

import React from "react";
import { FC } from "react";

export const Hello: FC = () => <div>Hello, world!</div>;

このとき FC はReactのJavaScript側コードには存在せず、 @types/react 内のinterfaceとしてのみ存在します。JavaScriptにトランスパイルするときにこれをそのまま残してしまうと、存在しないインポートとなってしまい、処理系のES Modulesの扱いによってはエラーになってしまいます。

これに対応するため、 import / export は一定ルールで消去されます。その詳細についてはBabel/TypeScript間の微妙な差異も含めて難しい点が色々存在するので本記事では深入りしません。

TypeScript 3.8以降では import type / export type 宣言が使えるようになったため、これらの難しい挙動に依存せずにコードを書くこともできるようになりました。

アノテーション系構文の詳細

let/const/varの型アノテーション

const x: number = 42;
//     ^^^^^^^^
let y: number = 42;
//   ^^^^^^^^
var z: number = 42;
//   ^^^^^^^^
for (let i: number = 0; i < 10; i++) {}
//        ^^^^^^^^
for (var j: number = 0; j < 10; j++) {}
//        ^^^^^^^^

シンプルなlet/const/varの場合は、通常以下のように型アノテーションを書きます。

/** @type {number} */
const x = 42;
/** @type {number} */
let y = 42;
/** @type {number} */
var z = 42;

2つ以上の宣言子がある場合は、上のような @type アノテーションは最初の宣言子に対してのみ適用されます。宣言子の直前の @type も認識されるため、これで対応することができます。

/** @type {number} */
const a = 42, /** @type {number} */ b = 42;

同じ方法でforの型アノテーションを与えられます。

for (let /** @type {number} */ i = 0; i < 10; i++) {}
for (var /** @type {number} */ j = 0; j < 10; j++) {}

catch引数アノテーション

catchの引数には any または unknown が指定できます。

try {} catch(e: any) {}
//            ^^^^^
try {} catch(e: unknown) {}
//            ^^^^^^^^^

JavaScriptでは @type で同様の指定ができます。

try {} catch (/** @type {any} */ e) {}
try {} catch (/** @type {unknown} */ e) {}

関数・メソッドの引数型アノテーション

function f1(x: number) {}
//           ^^^^^^^^
const f2 = function(x: number) {};
//                   ^^^^^^^^
const f3 = (x: number) => {};
//           ^^^^^^^^
class C {
  meth(x: number) {}
  //    ^^^^^^^^
  set something(value: number) {}
  //                 ^^^^^^^^
}

@param (別名: @arg, @argument) を使うのが一般的です。

/** @param {number} x */
function f1(x) {}
/** @param {number} x */
const f2 = function(x) {};
/** @param {number} x */
const f3 = (x) => {};
class C {
  /** @param {number} x */
  meth(x) {}
  /** @param {number} value */
  set something(value) {}
}

関数定義に対しては @type を指定することでも間接的に引数型を指定することができます。

/** @type {function(number): void} */
function f1(x) {}
/** @type {function(number): void} */
const f2 = function(x) {};
/** @type {function(number): void} */
const f3 = (x) => {};

関数・メソッドのthis引数型アノテーション

function f1(this: number[]) {}
//          ^^^^^^^^^^^^^^
const f2 = function(this: number[]) {};
//                  ^^^^^^^^^^^^^^
class C {
  meth(this: number[]) {}
  //   ^^^^^^^^^^^^^^
}

@this アノテーションを使うことができます。

/** @this {number[]} */
function f1() {}
/** @this {number[]} */
const f2 = function() {};
class C {
  /** @this {number[]} */
  meth() {}
}

@type を指定することでも間接的にthis引数型を指定することができます。

/** @type {function(this: number[]): void} */
function f1() {}
/** @type {function(this: number[]): void} */
const f2 = function() {};
class C {
  /** @type {function(this: number[]): void} */
  meth() { const x = this; }
}

関数・メソッドのオプショナル引数アノテーション

function f1(x?: number) {}
//           ^
const f2 = function(x?: number) {};
//                   ^
const f3 = (x?: number) => {};
//           ^
class C {
  meth(x?: number) {}
  //    ^
}

@param (別名: @arg, @argument) の名前を [] で囲うことでオプショナル引数を表明できます。

/** @param {number} [x] */
function f1(x) {}
/** @param {number} [x] */
const f2 = function(x) {};
/** @param {number} [x] */
const f3 = (x) => {};
class C {
  /** @param {number} [x] */
  meth(x) {}
}

型に = をつけてもオプショナルになります。

/** @param {number=} x */
function f1(x) {}
/** @param {number=} x */
const f2 = function(x) {};
/** @param {number=} x */
const f3 = (x) => {};
class C {
  /** @param {number=} x */
  meth(x) {}
}

@type で関数型を指定した場合、TypeScript 4.4時点ではオプショナルにはならないようです。

関数・メソッドの戻り値型アノテーション

function f1(): number { return 42; }
//           ^^^^^^^^
const f2 = function(): number { return 42; };
//                   ^^^^^^^^
const f3 = (): number => 42;
//           ^^^^^^^^
class C {
  meth(): number { return 42; }
  //    ^^^^^^^^
  get something(): number { return 42; }
  //             ^^^^^^^^
}

@returns (別名: @return) でアノテーションできます。

/** @returns {number} */
function f1() { return 42; }
/** @returns {number} */
const f2 = function() { return 42; };
/** @returns {number} */
const f3 = () => 42;
class C {
  /** @returns {number} */
  meth() { return 42; }
  /** @returns {number} */
  get something() { return 42; }
}

@type に関数型を指定することでもアノテーションできます。

/** @type {function(): number} */
function f1() { return 42; }
/** @type {function(): number} */
const f2 = function() { return 42; };
/** @type {function(): number} */
const f3 = () => 42;
class C {
  /** @type {function(): number} */
  meth() { return 42; }
  /** @type {function(): number} */
  get something() { return 42; }
}

クラスフィールド型アノテーション

class C {
  foo: number = 42;
  // ^^^^^^^^
}

@type が使えます。

class C {
  /** @type {number} */
  foo = 42;
}

アンビエントクラスフィールド型アノテーション

ECMAScript仕様上、「初期化子のないクラスフィールド宣言」と「宣言がない状態」は厳密には異なります。TypeScriptで型アノテーションをしつつクラスフィールド宣言を出力させたくない場合は declare を使います。

class C {
    declare foo: number;
}

JavaScriptで同等のアノテーションをするのはやや面倒です。

コンストラクタ内で初期化している場合は初期化処理が代入宣言とみなされるため、そこに @type でアノテーションをすることができます。 (コンストラクタ以外のメソッド内でも使えますが、optionalになってしまいます)

class C {
  constructor() {
    /** @type number */
    this.foo = 42;
  }
}

または、プロトタイプ代入宣言をつける手もありますが、コードに謎の式文が残ってしまうという点で厳密には元のコードと等価ではありません。

class C {
}

/** @type number */
C.prototype.foo;

クラスフィールド・メソッドのoptionalアノテーション

class C {
  foo?: number;
  // ^
  meth?() {}
  //  ^
}

調べた範囲内では、同等のアノテーションをJSDocで行うことは難しそうです。

クラスフィールドの存在表明

class C {
  foo!: number;
  // ^
}

調べた範囲内では、同等のアノテーションをJSDocで行うことは難しそうです。

クラスフィールド・メソッドの修飾子 (public/private/protected/readonly/declare/abstract/override)

class S {
  foo7: number | string = 42;
  meth5() {}
}

abstract class C extends S {
  public foo1: number = 42;
  protected foo2: number = 42;
  private foo3: number = 42;
  readonly foo4: number = 42;
  declare foo5: number;
  abstract foo6: number;
  override foo7: number = 42;
  
  public meth1() {}
  protected meth2() {}
  private meth3() {}
  abstract meth4(): void;
  override meth5() {}
}

declare, abstract 以外は同名のJSDocタグが認識されます。

class S {
  /** @type {number | string} */
  foo7 = 42;
  meth5() {}
}

// abstract classに対応するJSDoc実装はない
class C extends S {
  // 以降簡易的に複数のJSDocを1行にまとめている (一般的な書き方ではないので注意)
  /** @public @type {number} */
  foo1 = 42;
  /** @protected @type {number} */
  foo2 = 42;
  /** @private @type {number} */
  foo3 = 42;
  /** @readonly @type {number} */
  foo4 = 42;
  // declare foo5: number;
  // abstract foo6: number;
  /** @override @type {number} */
  foo7 = 42;
  
  /** @public */
  meth1() {}
  /** @protected */
  meth2() {}
  /** @private */
  meth3() {}
  // abstract meth4(): void;
  /** @override */
  meth5() {}
}

declare は前述の通り、別の箇所で代入宣言として書くことができれば同等の型アノテーションを再現することができます。 abstract は探した限りでは対応する機能は (今のところ) なさそうです。

クラスのindex signature

class C {
  [key: number]: string;
}

JSDocで等価な宣言を行う方法はおそらくありません。

as / レガシー型表明

const arr = ["a", 42].filter((x) => typeof x === "string");

const arr2 = arr as string[];
//               ^^^^^^^^^^^
const arr3 = <string[]>arr; // JSX有効時は使えない
//           ^^^^^^^^^^

e as T<T>e は同じものです (<T>e はJSXと思い切り衝突するため現在はあまり使われない)

JSDocで括弧式に @type アノテーションをつけると as と同等に扱われます。

const arr = ["a", 42].filter((x) => typeof x === "string");

const arr2 = /** @type {string[]} */ (arr); // 括弧が重要

as const

const x = { OPEN: "open" } as const;

as const は内部的には const を型名としてパースしたあと型チェッカーで特別扱いしています。TypeScript 4.5で@type を使って同じことができるようになりました

const x = /** @type {const} */ ({ OPEN: "open" });

non-null表明

const match = /a|$/.exec("abc")!;
//                             ^
console.log(match[0]);

JSDocで同等の表明は今のところできなさそうです。通常のasは使えるので、それである程度までは代用が可能です。

// asで代用
const match = /** @type {RegExpExecArray} */ (/a|$/.exec("abc"));
console.log(match[0]);

クラスのabstract修飾子

abstract class C {}

今のところJSDocでabstractを認識させることはできないようです。なお、(TypeScriptが実装していないだけで)JSDoc仕様には @abstract がありますが、これはabstract method側につけるもののようです。

クラスのimplements節

interface I {
    foo: number;
    bar(): void;
}
class C implements I {
    //  ^^^^^^^^^^^^
    foo: number = 42;
    bar() {}
}

JSDocでは @implements タグでimplementsを表明することができます。

// interfaceは書けないのでtype aliasで代用
/**
 * @typedef I
 * @property {number} foo
 * @property {function(): void} bar
 */

/** @implements I */
class C {
    /** @type {number} */
    foo = 42;
    bar() {}
}

関数・コンストラクタ呼び出しの型実引数

const arr = new Array<number>();
//                   ^^^^^^^^
arr.map<string>((x) => `${x}`);
//     ^^^^^^^^

JSDocで同等の記述を実現する方法は調べた限りでは無さそうです。他の位置のアノテーションから推論させることで代用できるかもしれません。

// 別の型アノテーションで代用する例

/** @type {number[]} */
const arr = new Array();
arr.map(/** @returns {string} */ (x) => `${x}`);

extendsの型実引数

class Numbers extends Array<number> {}
//                         ^^^^^^^^

JSDocでは @extends タグ (別名: @augments) でextendsを再表明することができます。

/** @extends Array<number> */
class Numbers extends Array {}

タグ付きテンプレートリテラルの型実引数

type Interpolation<P> = (string | ((props: P) => string))[];
function css<P>(texts: TemplateStringsArray, ...interpolation: ((props: P) => string)[]): Interpolation<P> {
  const i: Interpolation<P> = [];
  texts.forEach((text, index) => {
      i.push(text);
      if (interpolation[index]) i.push(interpolation[index]);
  })
  return i;
}

//               vvvvvvvvvvvvvvvvvvvvvv
const style = css<{ visible: boolean }>`
  color: #335577;
  display: ${(p) => p.visible ? "block" : "none"};
`;

JSDocで同等のアノテーションはなさそうです。他の位置のアノテーションで代用できないか考えるといいでしょう。

// 他の位置のアノテーションで代用する例

/**
 * @template P
 * @typedef {(string | ((props: P) => string))[]} Interpolation
 */

/**
 * @template P
 * @param {TemplateStringsArray} texts
 * @param {...(function(P): string)} interpolation
 * @returns {Interpolation<P>}
 */
function css(texts, ...interpolation) {
  /** @type {Interpolation<P>} */
  const i = [];
  texts.forEach((text, index) => {
      i.push(text);
      if (interpolation[index]) i.push(interpolation[index]);
  })
  return i;
}

/** @type {Interpolation<{ visible: boolean }>} */
const style = css`
  color: #335577;
  display: ${(p) => p.visible ? "block" : "none"};
`;

JSXの型実引数

import React from "react";

type ItemListProps<I> = {
  items: I[];
  renderItem: (item: I) => (React.ReactElement | null);
};
function ItemList<I>(props: ItemListProps<I>): React.ReactElement | null {
  return <>{props.items.map(props.renderItem)}</>;
}

const elem = (
  <ItemList<number>
    //     ^^^^^^^^
    items={[1, 2, 3]}
    renderItem={(item) => <a href={`https://example.com/${item}`}>Item {item}</a>}
  />
);

JSDocで同等のアノテーションはなさそうです。他の位置のアノテーションで代用できないか考えるといいでしょう。

// 他の位置のアノテーションで代用する例

import React from "react";

/**
 * @template I
 * @typedef ItemListProps
 * @property {I[]} items
 * @property {function(I): (React.ReactElement | null)} renderItem
 */

/**
 * @template I
 * @param {ItemListProps<I>} props
 * @returns {React.ReactElement | null}
 */
function ItemList(props) {
  return <>{props.items.map(props.renderItem)}</>;
}

// ItemList<number> だが、この場合は明示しなくてもちゃんと推論される
const elem = (
  <ItemList
    items={[1, 2, 3]}
    renderItem={(item) => <a href={`https://example.com/${item}`}>Item {item}</a>}
  />
);

関数・メソッドの型仮引数

function id1<T>(x: T) {
  //        ^^^
  return x;
}
const id2 = function<T>(x: T) { return x; };
//                  ^^^
const id3 = <T>(x: T) => x; // JSX有効時はそのままでは通らない
//          ^^^
const obj = {
  id<T>(x: T) { return x; }
  //^^^
};
class C {
  id<T>(x: T) { return x; }
  //^^^
}

@template で型仮引数を宣言できます。

/**
 * @template T
 * @param {T} x
 */
function id1(x) {
  return x;
}
/**
 * @template T
 * @param {T} x
 */
const id2 = function(x) { return x; };
/**
 * @template T
 * @param {T} x
 */
const id3 = (x) => x;
const obj = {
  /**
   * @template T
   * @param {T} x
   */
  id(x) { return x; }
};
class C {
  /**
   * @template T
   * @param {T} x
   */
  id(x) { return x; }
}

クラスの型仮引数

class MyArray<T> extends Array<T> {}
//           ^^^

型仮引数のextends制約

class PrimitiveArray<T extends number | string | boolean> extends Array<T> {}

@template{} で括った型を記載すると、それがextens制約になります。

/**
 * @template {number | string | boolean} T
 * @extends {Array<T>}
 */
class PrimitiveArray extends Array {}

型仮引数のデフォルト

type VFC<P = {}> = (props: P) => null;

TypeScript 4.5からデフォルト値を指定できるようになりました

/**
 * @template [P={}]
 * @typedef {function(P): null} VFC
 */

interface宣言

interface I {
  new(): number[];
  <T>(value: T): T;
  foo?: number;
  bar?(): void;
  readonly [Symbol.toStringTag]: string;
  [key: number]: string;
}

interface宣言をJSDocで行うことはできないようです。interface宣言はオブジェクトリテラル型のtype宣言で置き換えられることが多い (declaration mergingが効かないなどの違いがある) ので、そちらで代用することになるでしょう。

type宣言

type T = number | string;

@typedef で置き換えられます。

/** @typedef {number | string} T */

これだけで十分な汎用性がありますが、別の選択肢として以下があります。

// 単純なオブジェクトリテラル型の場合
/**
 * @typedef SimpleObjectType
 * @property {number} foo
 * @property {string=} bar
 */

// 単純な関数型の場合
/**
 * @callback FunctionType
 * @param {number} foo
 * @param {string=} bar
 * @returns {Promise<void>}
 */

また type 宣言が生成される特殊なタグとして @enum があります。

// type State = string; が生成される
/** @enum {string} */
const State = {
    OPENED: "opened",
    CLOSED: "closed",
};

なお次に説明するようにJSDoc由来で生成される type 宣言はモジュールレベル宣言の場合は自動的にexportされます。

型のexport

export type T = number;

export type U = number;
export { U as switch }; // キーワードも使える

JSDocの @typedef / @callbackで生成される type は自動的にexportされます

/** @typedef {number} T */
/** @typedef {number} switch */
export {}; // モジュールとして認識させるためのダミー宣言

// export type T = number;
// type _switch = number;
// export { _switch as switch };

export type宣言

class C {}
export type { C }; // 型定義だけエクスポートされる

JSDocで同じことはできないようです。

型のimport

import { Component, FC } from "react";

// クラス
export let elem: Component | undefined = undefined;

// 型エイリアス
export let comp: FC | undefined = undefined;

JavaScriptでも同様にimport宣言が使えますが、import eliminationが起こりません。型エイリアスのインポートは実行時にエラーになる場合があります。

import { Component, FC } from "react";

// クラス (OK)
/** @type {Component | undefined} */
export let elem = undefined;

// 型エイリアス (NG)
/** @type {FC | undefined} */
export let comp = undefined;

かわりにimport型を使うことができます。 (TypeScriptでも利用可能だが、import宣言のほうが便利なのであまり使わない)

/** @type {import("react").FC | undefined} */
export let comp = undefined;

import type宣言

import type { Component } from "react";

JavaScriptで同じことはできませんが、import型で代用できます。 (説明済みなので省略)

型のre-export

export { FC } from "react";

型のみの場合はJavaScriptで同じことをすることはできませんが、 @typedef である程度代用できます。

// デフォルト型引数が作れないため、厳密に同じではない
/**
 * @template P
 * @typedef {import("react").FC<P>} FC
 */

export {};

ところで急に話は変わるんですが、私は技術の「細部」を知ることには「全体像」とは異なる価値があると思っています。この段落を見つけた読者の皆さん、いつも私の記事を細部まで読んでいただきありがとうございます。

re-export形式のexport type宣言

export type { Component } from "react";

本当に型のみをエクスポートしたい場合JavaScriptで同じことをすることはできませんが、 @typedef である程度代用できます。

// デフォルト型引数が作れないため、厳密に同じではない
/**
 * @template P
 * @template S
 * @typedef {import("react").Component<P, S>} Component
 */

export {};

Legacy import宣言

import React = require("react");
//^^^^

JavaScriptモードではLegacy import宣言を使わなくても require("...") の形の式が全てインポートと解釈されます

const React = require("react");

Legacy export宣言

const value = 42;
export = value;
//^^^^^^

JavaScriptモードでは module.exports に代入することでLegacy export宣言と同等に解釈されます

const value = 42;
module.exports = value;

export as namespace 宣言

export as namespace は型定義ファイルでのみ使えます。

// .d.ts, .d.mts, .d.cts でのみ使える

declare namespace AwesomeUMDLibrary {}
export = AwesomeUMDLibrary;
export as namespace AwesomeUMDLibrary;

関数・メソッドのオーバーロード宣言

function multiply(base: string, by: number): string;
function multiply(base: number, by: number): number;
function multiply(base: string | number, by: number): string | number {
    if (typeof base === "string") {
        let result = "";
        for (let i = 0; i < by; i++) result += base;
        return result;
    } else {
        return base * by;
    }
}

JSDocによるオーバーロード宣言は実装されていません (関連issue)。

TypeScript 4.4時点ではoverloaded call signaturesで書いてもうまく動かないようです。

// overloaded call signatures による実装 (うまく動かない)

/**
 * @type {(function(string, number): string) & (function(number, number): number)}
 */
function multiply(base, by) {
    if (typeof base === "string") {
        let result = "";
        for (let i = 0; i < by; i++) result += base;
        return result;
    } else {
        return base * by;
    }
}

function declarationを使うのをやめて @type によるasキャストを使えばある程度動きます。

const multiply =
  /**
   * @type {(function(string, number): string) & (function(number, number): number)}
   */
  (function(base, by) {
    if (typeof base === "string") {
      let result = "";
      for (let i = 0; i < by; i++) result += base;
      return result;
    } else {
      return base * by;
    }
  });

他に以下のような機能を組み合わせたワークアラウンドが考えられます。

  • union type (引数の型だけ違う場合はこれでOK)
  • rest parameters as a tuple (引数の数や組み合わせが違うとき、union typeと組み合わせて使える)
  • generics + conditional types (引数型に応じて戻り値型を決める)

メソッドのオーバーロード宣言についても同様です。

アンビエント変数・関数・クラス宣言

declare const x: number;
declare function f(): void;
declare class C {}

JSDocで同等の宣言はできないようです。

アンビエントグローバル宣言

declare global {
  var root: HTMLDivElement;
}
export {};

JSDocで同等の宣言はできないようです。

モジュール拡張

declare module "react" {
    interface FunctionComponent {
        extraField?: number;
    }
}

JSDocで同等の宣言はできないようです。

namespace

namespaceはTypeScriptの独自機能ですが、JavaScriptでも特定のパターンはnamespaceと同等に扱われます。

// TypeScript
namespace utils {
  export type T = number;
  export function square(x: number) {
    return x * x;
  }
}
// JavaScript
/** @typedef {number} utils.T */
const utils = {};
/** @param {number} x */
utils.square = function(x) {
  return x * x;
}

型推論の詳細

デフォルト型引数

TypeScriptでは unknown にフォールバックされるのに対し、JavaScriptでは any になります。

const x = new Promise(() => {});
// TypeScript mode: Promise<unknown>
// JavaScript mode: Promise<any>

代入宣言

TypeScriptコンパイラは代入を擬似的な宣言としてみなすことがあります。特定のケースを除き、JavaScriptファイルでのみ有効な機能です。

プロパティ代入宣言

オブジェクト型の変数に代入すると名前空間として推論されます。

const ns1 = {};
ns1.ns2 = {};

ns1.ns2.decl1 = 42;
ns1.ns2["decl2"] = 42;
Object.defineProperty(ns1.ns2, "decl3", { value: 42 });

// declare namespace ns1 {
//   namespace ns2 {
//     const decl1: number;
//     const decl2: number;
//     const decl3: number;
//   }
// }

関数に対する代入でも名前空間が形成されます。この形だけ特別に、TypeScriptファイル内でも認識されます。

function f() {}

f.foo = 42;

// declare function f(): void;
// declare namespace f {
//   const foo: number;
// }

プロトタイプ代入宣言

関数の prototype 属性内への代入はクラスに推論されます。

function C() {}

C.prototype.decl1 = function() {};
Object.defineProperty(C.prototype, "decl2", { value: 42 });

// declare function C(): void;
// declare class C {
//   decl1(): void;
//   readonly decl2: number;
// }

prototype 属性自体を置き換えることもできます。

function C() {}

C.prototype = {
    decl1() {}
};

// declare function C(): void;
// declare class C {
//   decl1(): void;
// }

this 代入宣言

コンストラクタ内やメソッド内での this 内プロパティへの代入は型推論に使われることがあります。

class C {
  constructor() {
    this.foo = 42;
  }
}

// declare class C {
//   foo: number;
// }
class C {
  initialize() {
    this.foo = 42;
  }
}

// declare class C {
//   initialize(): void;
//   foo: number | undefined;
// }

CommonJSモジュール内の代入はエクスポートに推論されます。

module.exports.foo = 42;
this.bar = 42;

// export var foo: number;
// export var bar: number | undefined;

グローバルスクリプト内での代入はグローバル変数宣言に推論されます。

this.foo = 42;
// declare var foo: number | undefined;

CommonJSモジュールエクスポート宣言

module.exports または exports への代入宣言はモジュールエクスポートとみなされます。

exports.decl1 = 42;
module.exports.decl2 = 42;
Object.defineProperty(exports, "decl3", { value: 42 });
Object.defineProperty(exports, "decl4", { value: 42 });

exports.ns1 = {};
exports.ns1.decl5 = 42;

// export var decl1: number;
// export var decl2: number;
// export var decl3: number;
// export var decl4: number;
// export namespace ns1 {
//   const decl5: number;
// }

module.exports を直接置き換えることもできます。

module.exports = {
    decl1() {},
    decl2() {},
};

// // TypeScript 4.4時点ではなぜか二重に出力される
// export function decl1(): void;
// export function decl1(): void;
// export function decl2(): void;
// export function decl2(): void;

コンストラクタ関数の型付け

JavaScriptファイルの場合のみ、 function 式や宣言をクラスと同等に扱う仕組みがあります。

// class PackageVersion として扱われる (自動推論)
/**
 * @param {string} pkgname
 * @param {string} version
 */
export function PackageVersion(pkgname, version) {
  this.pkgname = pkgname;
  this.version = version;
}

コンストラクタ定義メンバの処理

コンストラクタ内で this のプロパティに代入すると、推論された型に基づいてクラスフィールドが宣言されたことになります。

class C {
  // x: number; // 推論される
  constructor() {
    this.x = 42;
  }
}

なお、TypeScriptでも「noImplicitAny が有効」かつ「クラスフィールド宣言が存在する」ときに同じ仕組みで型が決められます。

// TypeScriptモード、 noImplicitAnyが有効
class C {
  x; // number型に推論される
  constructor() {
    this.x = 42;
  }
}

CommonJS exports / module.exportsの扱い

JavaScriptで書かれたCommonJSモジュールの場合は exports のプロパティへの代入がエクスポートとして特別に推論されます。

// export var foo: number; に推論される
exports.foo = 42;
// declare const _exports: number; export = _exports; に推論される
module.exports = 42;

CommonJS requireの扱い

JavaScriptで書かれたCommonJSモジュールの場合は require("...") がLegacy import構文と同等に扱われます。

構文的曖昧性の詳細

関数・コンストラクタ呼び出しの型実引数

関数呼び出しやnewにはジェネリクス引数を指定することができます。

const arr1 = Array<number>(0);
const arr2 = new Array<number>(0);

ところが、これは厳密にはJavaScriptの構文と衝突します。

// JavaScriptとしてパースして、わかりやすさのために括弧をつけ直すとこうなる
const arr1 = (Array < number) > 0;
const arr2 = ((new Array) < number) > 0;

この曖昧性が実際に問題になる例が#36662で挙げられています。このような理由から、JavaScriptでは以下がパースされません。 (TypeScript 4.2以降の仕様)

  • 関数呼び出しの型実引数 Array<number>()
  • コンストラクタ呼び出しの型実引数 new Array<number>()
  • クラスの継承元 class Numbers extends Array<number> {}
  • JSXの型実引数 <ItemList<Item> />
    • これは曖昧性はなさそうですが、#36673では関数呼び出し・コンストラクタ呼び出しとあわせて禁止するように変更されています。

@type の使い方一覧

関数・メソッド

関数やメソッドのアノテーションには通常 @param / @returns / @this を使いますが、 @type でも代用できるようになっています。

/** @type {function(number): number} */
function square(x) {
    return x * x;
}

Parenthesized expression

丸括弧のうち、構文の優先順位を調整するなどの目的で使われる丸括弧 ((1 + 1) * 2(0, foo).bar など) をParenthesized expression といいます。 Parenthesized expressionに @type がついていると as による型キャストと同等の効果が得られます。 (→getContextualType, checkParenthesizedExpression)

// (21 + 21) as 42
const x = /** @type {42} */ ( 21 + 21 );

// as と同様、明らかに怪しいキャストはエラーになる
const y = /** @type {number} */ ( "foo" + "bar");

オブジェクトリテラルのメンバー

オブジェクトリテラルの各メンバーに @type をつけると型アノテーションとして機能します。

const { foo } = {
    /** @type {number | string} */
    foo: 42,
};

catch(e)

catch で受け取る変数は any または unknown 型になりますが、JSDocの場合は @type で型を指定できます。

// fの戻り値型に注目
function f() {
  try {
    //
  } catch (/** @type any */ e) {
    return e;
  }
}
function g() {
  try {
    //
  } catch (/** @type unknown */ e) {
    return e;
  }
}

Assignment declaration

TypeScriptの型検査器では、代入式は特定の条件下で宣言と同等に扱われます。特に左辺がExpando Initializerの場合、そこに @type をつけることでヒントとすることができます

const x = {};

/** @type {{ bar?: number }} */ 
x.foo = {};

Special property declaration

プロパティを読み取るだけの文に @type をつけると Special property declaration とみなされ、代入先オブジェクトが名前空間として扱われます。

const x = {};

/** @type {number} */
x.foo;
// x["foo"], x[0], x[Symbol.something] 等もOK

JSDoc内の型の構文

JSDocで型が要求される場所 (大抵は {} で囲まれている) にはTypeScriptの型を書くことができます。

/** @type {number | string} */
let x = 42;
x = "foo";

通常のTypeScriptの型 (関数戻り値に書けるもの) に加えて、JSDocでは以下の構文が追加でパースされます。

JSDoc TypeScript
* any
? any
?T
T?
T | null
!T
T!
T
T= T | undefined
function(T0, T1): T2 (arg0: T0, arg1: T1) => T2
...T T[]
T.<Arg0, Arg1> T<Arg0, Arg1>
module:T any
String string
Number number
Boolean boolean
Void void
Undefined undefined
Null null
function Function
array any[]
promise Promise<any>
Object<number, T> Record<number, T>
Object<string, T> Record<string, T>
Object any
  • ?T, T?
    • 前置と後置に意味的な違いはない。
    • strictNullChecksが無効の場合は ? は無視され、中身 T がそのまま使われる。
  • !T, T!
    • 前置と後置に意味的な違いはない。
    • TypeScriptは ! の意味を特に解釈しない。 number!number と同じで、 number?!number? と同じ。
  • function(T0, T1): T2
  • T=
    • JSDocタグ直下の型と function() の引数位置で使用可能。
    • @param, @property の型で使うとオプショナル引数・オプショナルプロパティとして扱われる。
    • TypeScript 4.4時点では、 function() の引数位置やクラスプロパティの型アノテーションで使われた場合はオプショナル引数・オプショナルプロパティとしては扱われない。
  • ...T
    • JSDocタグ直下の型と function() の引数位置で使用可能。
    • 最後の引数でのみ使用可能。
    • 配列型ではなく要素の型を指定する。 (function(...number): number(...args: number[]) => number と解釈される)
  • module:T
    • JSDocのnamepath仕様で混乱しないようにパースするようになっているが、その意味はTypeScriptは解釈しない。
    • かわりに any 型として扱われる。
  • array, promise, Object
    • noImplicitAny が無効のときだけ解釈される。 (結果が any を含むため)
  • Object
    • 特殊パターンである Object<number, T> / Object<string, T> に該当しない、かつ noImplicitAnyが有効なときは通常通り Object (object ではない) として解釈される。

ただし、いくつかのJSDoc型は直接 .d.ts にシリアライズしたとき (serializeExistingTypeNode) と型チェッカーの処理を経由したとき (getTypeFromTypeNodetypeToTypeNode) で挙動に一貫性がありません。TypeScript 4.4時点での挙動差異として以下があります。

  • ? (JSDocUnknownType)
    • 直接シリアライズすると unknown になる。
    • 型システム上は any として扱われる。
  • ...T (JSDocVariadicType) が関数のrest parameter以外で使われたとき
    • 直接シリアライズすると T[] になる。
    • 型システム上は T | undefined として扱われる。
  • ?T / T? (JSDocNullableType)
    • 直接シリアライズすると T | null になる。
    • 型システム上も T | null として扱われるが、 --strictNullChecks がオフのときは T として扱われる。
  • T= (JSDocOptionalType)
    • 直接シリアライズすると T | undefined になる。
    • 型システム上も T | undefined として扱われるが、 --strictNullChecks がオフのときは T として扱われる。
  • module:T (JSDocNamepathType)
    • 直接シリアライズすると any になる。
    • 型システム上は any の亜種である errorType として扱われる。

更新履歴

  • 2021-11-06 公開。
  • 2021-11-20 TypeScript 4.5 の変更点を反映。

Discussion

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