Help us understand the problem. What is going on with this article?

TypeScriptをプロダクト開発に使う上でのベストプラクティスと心得

eye.png

TypeScript - JavaScript that scales.

画像はTypeScript最高っていう顔です。

JavaScriptに触れ始めてから13年ほど経ちますが、TypeScriptに触れ始めたのは4年前──。キッカケはAngular 2のbeta版です。TypeScriptコミッターでも初期のバージョンからずっと動向を追いかけていたわけでもありませんが、この4年間ほぼ毎日TypeScriptをWebアプリケーション開発で触れてきたので、個人的ベストプラクティスをまとめてみます。

経験則ではありますが、公私ともに小規模中規模問わず様々なプロジェクトで通用してきたやり方であるため、多くのプロジェクトでも通用する開発手法であると思います。

おことわり
  • 記述している内容は、あくまでWebアプリケーション開発における知見であり、ライブラリ開発などでは事情が異なる場合があります
  • 本記事で述べている“規模”とは、開発人数/コード量/利用者数が多いことを指す曖昧な言葉であり、厳密な定義はご想像におまかせします
  • 筆者はReact/Reduxを利用することが多いため、用例はそれらを用いることが多いものの、記事内容自体はそれらに依存しません
  • 内容はTypeScript 3.7.5をベースとしたものです

1. トランスパイラの特性を知る/正しく選択する

TypeScriptはWebブラウザ上で動かないため、JavaScriptにトランスパイル1しなければいけません。このトランスパイルを行うツールとしてまず挙げられるのが、Microsoftが提供する tsc です。その他にも webpack や Babel でもトランスパイルは可能です。

トランスパイラ 利点 欠点 備考
tsc Microsoft製で最新バージョンに対応/全機能利用可能 Path Aliasesの未解決/旧ESへの互換性が不完全 --watch オプションで差分トランスパイルも可能
webpack(ts-loader) webpackのloader/全機能利用可能 プロジェクトの肥大化で遅くなる パフォーマンス改善方法あり
Babel 旧ESへの変換が優秀 型チェックを行わない/トランスパイルが高速 Re-exports問題をはらむ

同じTypeScriptという言語を利用する場合においても、トランスパイラによってTypeScript自体の機能制限がかかったり、思わぬトラブルを招く場合があります。それぞれのトランスパイラの特徴を踏まえた上で、それにより生じる問題も見ていきましょう。

1-1. tsc

TypeScriptの開発元であるMicrosoft純正のTypeScriptトランスパイラです。TypeScriptを利用する際に typescript パッケージをインストールする必要がありますが、それに同梱されています。

公式ツールなだけあって最も早く最新バージョンのTypeScriptに対応したり、言語すべての機能を利用することができる一方で、バンドラではないためminifyやchunkの設定はできません。また、Path Aliasesの未解決や旧ESへの互換性が不完全であることが欠点として挙げられます。

tsconfig.json には compilerOptions.target オプションで、トランスパイル後のECMAScriptバージョンを指定することができます。つまり、Babelのように後方互換性のあるコードを生成できるわけです。しかし指定したバージョンへのトランスパイルはBabelほど精度の高いものではなく、TypeScript 2.1までは Async Await をES5/ES3へトランスパイルできなかったり、執筆時時点の最新バージョンTypeScript 3.7においては globalThis がそのままトランスパイル後のファイルへ出力される……など、 tsc だけでプロダクト開発をすることは難しい印象があります。

そのような理由から、 多くのプロジェクトでは webpack や Babel を選択することが多く、 tsc はTypeScriptの型チェックツールとして使われたり、ライブラリ開発などで用いられることが多いのが現状 です。

1-1-1. Path Aliases と tsc の未解決問題

TypeScriptのESM(ECMAScript Modules)構文では、 node_modules などの外部パッケージを取り込む場合を除き、インポート対象のパスを相対パスで記述しなければいけません。Reactを始めとした“コンポーネント”という小さな単位でUIを作り上げる開発手法が主流になった今、ディレクトリの階層が深くなることは多々ありますし、そのような場合に ../../../../foo などとディレクトリを巡るパスを記述するのは苦行でしかありません。

そこでプロジェクト内の特定のパスに対してエイリアスを設定できる機能が Path Aliases となります。 tsconfig.jsoncompilerOptions.paths より設定可能です。 Path Aliases のターゲットとなるパスは compilerOptions.baseUrl を基準とした相対パスで記述します。

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@models/*": ["./models/*"]
    }
  }
}

上記のようにワイルドカード * を用いたエイリアス設定を行うことで、プロジェクト内のどのファイルからも @models/src/models 内のファイルパスを参照できるようになります。なお、 @ を接頭辞としているのは Path Aliases であることを示す僕の個人的な習慣です。エイリアス名がパッケージ名と衝突した場合、当該パッケージを import 構文で読み込むことは難しくなるため、パッケージ名にかぶらないような命名規則にしておくと吉です。

/src/components/home/header/account/navigation/list.component.tsx
- import { UserModel } from "../../../../../models/user.model";
+ import { UserModel } from "@models/user.model";

TypeScriptの構文エラーや参照エラーにもならず、またVS Codeなどのエディタでは通常のパス指定と同様にコードジャンプも有効です。

しかし、 tsc で Path Aliases を含むコードをトランスパイルすると、エイリアス名がそのまま出力され、実行時エラーを招きます。

/src/components/home/header/account/navigation/list.component.js
"use strict";
exports.__esModule = true;
var user_model_1 = require("@models/user.model"); // 存在しないファイルを `require` している
console.log(user_model_1.UserModel);

公式トランスパイラである tsc で Path Aliases のパスが解決されないことに疑問を持つ開発者は多く、実際にこれを指摘するIssueがTypeScriptリポジトリに立てられ、Issue内の議論も荒れに荒れていますが、「パス解決は tsc が担うべき機能ではない」という理由でクローズされています。

したがって、 Path Aliases のパスを解決するには、後述する webpack などを利用するか、 tsconfig-paths のようなパッケージを利用する必要があります。

1-2. webpack(ts-loader)

TypeStrong/ts-loader: TypeScript loader for webpack

人によっては好き嫌いが分かれる webpack ですが、プロダクションレベルでWebフロントエンドアプリケーションを開発する際においては、必需品かつデファクトスタンダードと言っても過言ではないでしょう。そんな webpack ですが、TypeScriptをトランスパイルするローダも開発されており、最も代表的なものが ts-loader です。

ts-loader には transpileOnly オプションが設けられており、これを true に指定することで型チェックのスキップと引き換えに高いパフォーマンスを得ることができます。一方で webpack 4 を利用する場合、後述する Re-exports 問題を誘発することになるため、大量のWarningを抑制するための設定が必要となります。

1-2-1. awesome-typescript-loader

以前の ts-loader はパフォーマンスが低く、TypeScriptのトランスパイルに時間を要する問題が存在していました。そこで awesome-typescript-loader というローダも登場しましたが、しばらくするとメンテナンスされなくなり、現在ではGitHubリポジトリがアーカイブ化されています。

今となっては ts-loader のパフォーマンスも改善され、また先述の transpileOnly オプションも提供されていることもあり、開発が停止した awesome-typescript-loader を利用するメリットはないでしょう。しかし、Storybookを含むいくつかのライブラリではいまだに awesome-typescript-loader の利用を推奨しているため注意が必要です。

1-2-2. 型チェック方法と fork-ts-checker-webpack-plugin

先述の通り ts-loader の transpileOnly オプションはパフォーマンスの向上に期待できますが、型チェックが行われなくなります。VS Codeなどのエディタ上でもリアルタイムに型チェックが行われますが、やはりビルド時にも型チェックを行ってほしいもの。

そこで型チェックの方法の1つとして、 tsc を利用するものが挙げられます。 tsc はそれ単体でトランスパイラではありますが、 --noEmit オプションを指定することによってファイルの生成を防ぐ──すなわち型チェックのみを実行できます。僕が携わるプロジェクトでは、npm-run-scriptsの lint コマンドにこれを割り当てています。

package.json
{
  "scripts": {
    "lint": "tsc --noEmit"
  }
}

もう1つの代表的な方法としては、ランタイムでも型チェックを実行する webpack 用プラグイン fork-ts-checker-webpack-plugin が挙げられます。独立したプロセスでチェックが走るため、 ts-loader などのトランスパイル処理に影響を与えることがありません。Reactのフレームワークである Next.js のバージョン9からは、このプラグインが標準で採用されています。

1-3. Babel

Babel · The compiler for next generation JavaScript

Babelは、2018年の夏頃にリリースされたバージョン7よりTypeScriptのトランスパイルを標準でサポートしています。TypeScriptのトランスパイルを行うには、 @babel/preset-typescript プリセットが必要となります。

最新のECMAScript仕様のJavaScriptを用いるプロジェクトのほとんどは Babel を利用しているでしょうから、プリセット1つを設定に追加するだけでTypeScriptの利用を開始できるのは嬉しいポイントの1つです。一方、あくまで Babel の拡張機能の1つであるため、JavaScriptへのトランスパイル以外の処理は実行できません。

1-3-1. TypeScriptへの機能制限

Babel によるTypeScriptのトランスパイルは、一部のTypeScriptの機能が制限されます。Const Enumsはそのうちの1つで、これを利用することはもちろん、これを利用したライブラリをトランスパイルすることもできません。

BabelがトランスパイルできないConst-Enums
export const enum Status {
       ^^^^^
  // 'const' enums are not supported.
  Published,
  Draft,
}

僕が気に入っている ts-key-enum というライブラリは、TypeScriptのEnumsを利用して event.key の値を参照できるというものですが、バージョン3ではConst Enumsを利用するという変更が行われました。僕のプロジェクトではBabelを利用してTypeScriptをトランスパイルしているため、Const Enumsを利用している ts-key-enum のバージョン3は使えず、バージョン2を使い続ける必要があります。

実際に @babel/preset-typescript が行うことは、TypeScriptファイルから型情報を取り除くだけです。型チェックを行わないどころか、後述する Re-exports 問題が生じます。

1-3-2. インデックスファイルによるモジュール参照のスリム化

中規模以上のアプリケーションになってくると、1ファイルあたりの import 記述量やあらゆるファイルからのモジュールを参照する場面が増えてくるでしょう。そこで、とあるディレクトリ内に存在するファイルを外部から容易に参照できるよう、慣習的に index.ts というインデックスファイルを作成し、その中でモジュールを再エクスポートする Re-exports を行うことがあります。

/src/components/header/index.ts
// 各コンポーネントを Re-exports
export { LogoComponent } from "./logo.component";
export { NavigationComponent } from "./navigation.component";

ESM(ECMAScript Modules)やCommonJSにおいてインポート対象のパスをディレクトリ名で終えた場合、 対象ディレクトリ内の index.js または index.ts を参照するようになっているため、インデックスファイルを記述することで以下のようにスッキリとモジュールを参照できるようになります。

// Before
import { LogoComponent } from "./components/header/logo.component";
import { NavigationComponent } from "./components/header/navigation.component";

// After
import { LogoComponent, NavigationComponent } from "./components/header";

パスの記述もスッキリし、1つの import 文で複数のモジュールを取り込めるようになるため、ファイル内のモジュール参照記述量を削減できます。加えて index.ts から Re-exports されていないファイルの参照を禁止するルールを設けることで、モジュール1つ1つの依存/影響範囲を抑えることができます。

1-3-3. Re-exports 問題

webpack の transpileOnly オプションを有効にした場合や、Babelの @babel/preset-typescript を利用した場合、この Re-exports が大量のWarningを吐いてしまう問題をはらんでいます。具体的には、Type AliasesやInterfacesなどの型情報を Re-exports した場合に生じます。

Babelなどが行う型情報の削除のイメージ
- const num: number = 123;
+ const num = 123;
- const getUserName: string = (user: userModel.UserModel) => {
+ const getUserName = (user) => {

これらのトランスパイルは、あくまでTypeScriptファイルから型情報を削除するだけで、型チェックも型解決も行いません。TypeScriptはJavaScriptのスーパーセットであり、型情報を削除すればJavaScriptと変わらないという言語設計があるからこそ、こういった処理だけでトランスパイルが可能なわけですね。

// /src/models/user.model.ts
export type UserModel = {
  id: number;
};
export const createUser = (json: JSON) => ({
  id: json.id,
});

// /src/models/index.ts
export { UserModel, createUser } from "./user.model";

上記のような場合を考えてみましょう。 user.model.ts ではType Aliasesを用いてモデルを定義しており、同一階層の index.ts でモデルの Re-exports を行っています。

// /src/models/user.model.ts
/* 型情報なので削除される
export type UserModel = {
  id: number;
};
*/
export const createUser = (json: JSON) => ({
  id: json.id,
});

// /src/models/index.ts
/* UserModelは削除されているため、Re-exportできない! */
export { UserModel, createUser } from "./user.model";

型情報を削除するだけの Babel などでトランスパイルをした場合、まず最初に user.model.tsUserModel Type Aliasが削除されます。しかし Re-exportsを行うはずの index.ts では、既に存在しない UserModel を参照してしまうためWarningが生じてしまうわけです。

これを根本的に解決するには、 Babel などが厳密な型チェックや型解決を行う必要がありますが、 Babel の役割やパフォーマンスを考えると、それを採用することはまず考えられないでしょう。

webpack-filter-warnings-plugin を利用することで、このWarningを抑制することも可能ですが、何も設定せずに回避できる唯一の策は import * as による全モジュールのインポートです。

/src/models/index.ts
// UserModelは削除されているが、明示的に Re-exports 対象を指定していないため、Warningが生じない
import * as userModel from "./user.model";

export { userModel }; // `userModel.UserModel` `userModel.createUser` などで参照可能

tsc による型チェックやVS Codeなどのエディタ上のチェックで、この Re-exports 問題を未然に防ぐには、 tsconfig.jsoncompilerOptions.isolatedModules オプションを true にします。これにより、型情報の Re-exports を行った時点で型エラーとして扱われるようになります。

/src/models/index.ts
// compilerOptions.isolatedModules: true
export { UserModel, createUser } from "./user.model";
         ^^^^^^^^^
/* Cannot re-export a type when the '--isolatedModules' flag is provided.ts(1205) */

結論として Babel などの型チェックを行わないツールを、TypeScriptのトランスパイラとして利用する場合は、

  1. 型定義の明示的な Re-exports をしない(Named Exports含む)
  2. compilerOptions.isolatedModulestrue にする

の2点に気をつける必要があります。

2. 型参照を正規化する

TypeScriptでは型定義を構成する型情報の一部も参照できるため、 その型定義に関連するコンテキストにおいては、できる限り正規化された型参照を行うべき です。

例えば、僕が携わるプロジェクトではモデルをType Aliasesで定義することが多いわけですが、このモデルに関する関数や値に対して型情報を与えたい場合、標準型名の代わりにモデルのプロパティ型参照を行うようにしています。

/src/models/user.model.ts
export type UserModel = {
  id: number;
  name: string;
  email: string;
};
/src/components/users/user.component.tsx
import { userModel } from "@models/index";

type Props = {
  // `name: string;` と書かない
  name: userModel.UserModel["name"];
}

export const UserComponent = (props: Props) => (
  <div>
    {props.name}
  </div>
)

import 文の記述コストは掛かってしまいますが、これによる利点は2つあります。

  1. 型情報が変更された場合でも、影響範囲が明確になり、また型チェックの恩恵を受けられる
  2. 関連型へのコードジャンプが可能になる

2の利点はプロジェクトが複雑になるほど恩恵を受けられると思います。
上記のコード例では、 UserComponentUserModel に関連する、ユーザ情報を表示する関数(コンポーネント)です。ここからモデルの定義へ即座にジャンプできるようになると、ビューへの表示処理を記述する際のデータの確認が楽になります。

3. Enumsを使わない

TypeScriptにはEnumsが存在しますが、 Enumsのユースケースはほとんどありません 。特に、TypeScript 2.4から追加された、値にstring型を持てるString Enumsを利用する場合は、完全にオブジェクトへ置き換えることが可能です。

enum Status {
  Published = "published",
  Draft = "draft",
}

// 上記String Enumsは、オブジェクトで代用可能
const status = {
  Published: "published",
  Draft: "draft",
};

Enumsではなく、オブジェクトを利用する理由は以下が挙げられます。

  1. 先述の通り Babel などではConst Enumsが利用できない
  2. Enums値として自由な値を扱える
  3. Enumsを引数に取るような関数を呼ぶ場合、当該Enumsを毎回 import で参照しなければならない
  4. 外部入力値がEnumsに準拠した値であるかのチェックが容易

3-1. Enums値として自由な値を扱える

type Status = {
  published: "published",
  invisible: "draft" | "deleted",
};

const status: Status = {
  published: "published",
  invisible: "deleted",
};

Enumsでは値として、数値か文字列のどちらかしか利用できませんが、オブジェクトであれば真偽値などの他の型、Type Annotationsを組み合わせればさらにUnion Typesも利用可能です。

3-2. Enumsを引数に取るような関数を呼ぶ場合、当該Enumsを毎回 import で参照しなければならない

Enumsの場合
// status.ts
enum Status {
  Published,
  Draft,
}

// func.ts
export const updateStatus(status: Status) = () => ...;

// main.ts
import { Status } from "./status";
import { updateStatus } from "./func";

updateStatus(Status.Published);

Enumsを引数に取るような関数を呼ぶ場合、実引数でもEnumsからキーの指定が必要になるため、当該Enumsを参照しなければいけません。Swiftの .Published のように、キー名を直接記述できる型推論が存在すれば、まだ利用しやすいのですが……。

オブジェクトの場合
// status.ts
const status = {
  published: "published",
  draft: "draft",
};

// func.ts
export const updateStatus(status: keyof typeof status) () => ...;

// main.ts
updateStatus("published"); // ハードコーディングのように見えるが型チェックが有効
updateStatus("foo"); 
             ^^^^^
/* Argument of type '"foo"' is not assignable to parameter of type '"published" | "draft"'.ts(2345) */

オブジェクトならばオブジェクト自体を参照する必要がなく、またLiteral Typesによる型チェックも効くため型安全性は保たれます。

3-3. 外部入力値がEnumsに準拠した値であるかのチェックが容易

Enumsの場合
const checkValidStatus = (value: string): value is Status => {
  if (value === Status.Published) return true;
  if (value === Status.Draft) return true;
  ...

  return false;
};

JSONの受け取りやユーザからの入力など、外部入力値の検証が容易なのもオブジェクトの特徴の1つです。
ループ処理でEnumsの各キーや値を連続的に参照することはできないため、とある値がEnumsの値に含まれているかどうかを判別したい場合、1つずつ一致しているか確かめる必要があります。

オブジェクトの場合
const checkValidStatus = (value: string): value is keyof typeof status =>
  Object.values(status).includes(value);

オブジェクトの場合はキーや値のループが可能であり、 Array.prototype.includes を用いればワンラインで検証可能です。

4. TypeScript/フレームワークの標準型定義を活用する

TypeScriptも、ReactなどのUIフレームワークも、開発に役立つ標準型定義が提供されています 。自分で型を定義しても良いですが、十分にテストされ開発者間で共通の知識ともなる標準型定義を利用しない手はありません。

例えばReactの場合 @types/react によって型定義が提供されていますが、 ComponentProps 型定義を利用することでコンポーネントからPropsの型を抽出することが可能です。

ComponentProps型を利用しない場合
// foo.comopnent.tsx
export type Props = { ... };
export const FooComponent = (props: Props) => ...;

// other.component.tsx
/* 型名が衝突しないように別名をつけなければならない */
import { Props as FooComponentProps, FooComponent } from "./foo.component";

type Props = { ... } & Pick<FooComponentProps, "xxx">;
export const OtherComponent = (props: Props) => (
  ...
  <FooComponent xxx={props.xxx} />
  ...
);
ComponentProps型を利用する場合
// foo.comopnent.tsx
type Props = { ... };
export const FooComponent = (props: Props) => ...;

// other.component.tsx
import { FooComponent } from "./foo.component";

type Props = { ... } & Pick<React.ComponentProps<typeof FooComponent>, "xxx">;
export const OtherComponent = (props: Props) => (
  ...
  <FooComponent xxx={props.xxx} />
  ...
);

よくReactのプロジェクトで export type Props と記述されたコードを見かけますが、 ComponentProps でPropsの抽出が可能なので、覚えておくと便利です。

5. InterfacesではなくType Aliasesを利用する

最近TypeScriptに触れた人にとっては違いがイマイチぱっとしないInterfacesとType Aliasesですが、 現在はInterfacesをアプリケーション上で利用することはほとんどありません

どちらもクラスのインターフェースとして利用できますし、型に関するエラー情報も変わりません。古いTypeScriptのバージョンではInterfacesの方が使い勝手が良かったものの、現在ではType Aliasesの方が圧倒的に使いやすいでしょう。
Type Aliasesでは Union Types | を扱うこともできますし、 Intersection Types & による型のマージは使う機会も多いと思います。

以下は僕が実際に利用している、Reactとreact-reduxにおけるContainer Componentの定義例です。

user-information.component.tsx
// Interfaces ではなく Type Aliases で Props を定義
type Props = {
  user: userModel.UserModel;
  updateUser: (name: userModel.UserModel["name"]) => void;
  updateRequestStatus: RequestStatus;
};
export const UserInformationComponent = (props: Props) => ...;
user-information.container.ts
import { UserInformationComponent } from "./user-information.component";

type Props = React.ComponentProps<typeof UserInformationComponent>;
type StateProps = Pick<Props, "updateRequestStatus">;
type DispatchProps = Pick<Props, "updateUser">;
type OwnProps = Omit<Props, keyof (StateProps & DispatchProps)>;

export const UserInformationContainer = connect(
  (state): StateProps => ({ ... }),
  (dispatch, props: OwnProps): DispatchProps = > ({ ... }),
)(UserInformationComponent);

6. TypeScript独自のモジュール形式(TSM)を利用しない

// module.ts
export = 123;

// main.ts
import number = require("./module");

結論から述べると、 上記のようなTypeScript独自のモジュール形式を使用してはいけません

6-1. ESMとCommonJSのDefault Exports/Imports

ライブラリやサーバサイドNode.jsならまだしも、今からWebフロントエンドアプリケーション開発を行う場合はESMを利用することがほとんどだと思います。

元来JavaScriptにはモジュールという仕組みが存在せず、歴史的経緯から現在では2つのモジュール形式──ESM(ECMAScript Modules)とCommonJSが主流となっています。モジュールシステムの先駆けともなったCommonJSはNode.jsで生まれたこともあり、サーバサイドNode.jsやライブラリなどで多く利用されていますが、その後ECMAScriptでESMが策定されたことにより、今後はESMの利用が(緩やかにではありますが)活発になっていくことでしょう。

ESMやCommonJSなどのモジュールシステム間には、互換性がありません。さらにNode.jsで当たり前のように利用されているモジュールシステムは、厳密にはCommonJSの仕様を参考にしたものであって、CommonJSではありません
CommonJSにおいては、“エクスポート対象となる値はプロパティを持つオブジェクトである”と定義されています2。Default Exportsを行う場合、慣用的に default プロパティに値をセットします。一方でNode.jsは module.exports 自体に値を渡すことで、その値をDefault Exportsとして扱う仕様となっており、同じCommonJSを採用した(と言われている)プラットフォームでもDefault Exportsの仕方が異なるのが現状です。

// Node.js独自のDefault Exports
module.exports = 123;

// CommonJS本来のDefault Exports(慣習)
module.exports.default = 123;

// Node.jsでDefault Importsをする場合、 `module.exports.default` を明示的に参照しなければならない
/* `module.exports = 123` の場合 */
require("./module"); // 123

/* `module.exports.default = 123` の場合 */
require("./module"); // { default: 123 }
require("./module").default; // 123

一方で、今後デファクトスタンダードになるであろうESMのDefault Exportsの仕様としては、“ export default xxx でエクスポートした値は default プロパティを持つオブジェクトとしてエクスポートされる”と定義されています3。また“ import xxx from でインポートされる値は、エクスポートされた値の default プロパティを参照する”とも定義されています4。すなわち、Node.jsでよく用いられる module.exports = xxx などのエクスポート方法は、ESMのこの仕様にも反することになるわけです。

// ESMのDefault Exportsの仕様上、以下のどちらも仕様的に正しい
export default 123;
module.exports.default = 123;

// ESMのDefault Importsの仕様上、上記のエクスポートされた値は以下の方法で読み込めるのが正しい
import number from "./xxx"; // 123

6-2. TypeScript Modules

Babel ではバージョン6より、Node.jsの module.exports = xxx の書き方をしようが、ESMの仕様に沿ったDefault Exportsを利用しようが、Default Imports時にエクスポートされた値自体を参照するのか、 default プロパティの値を参照するのかを自動判別するようになりました。
しかしTypeScriptのDefault Importsは、ESMの仕様に準拠していることから、 Babel とは異なりNode.jsのDefault Exportsに対応していません。そこで TypeScriptが用意した、独自のモジュールシステムTSM(TypeScript Modules:勝手に命名しています) の記述方法が、以下のコードとなります。

// module.ts
/* `module.exports = 123` と同じ */
export = 123;

// main.ts
import number = require("./module"); /* `default` プロパティは参照しない */

この記述方法は、Node.jsのDefault Exportsを利用したライブラリが型定義を提供する際に用いるべきものであって、 アプリケーションを開発する我々が使用するべきではありません 。我々が仕様すべきはESMに沿った記述方法です。しかしながら、TSMの export = xxx とESMには互換性がないため、もしTSMを利用したライブラリをアプリケーションで使いたい場合、ESMのインポート方法を利用できないのでしょうか。

結論から述べると、 TypeScript 2.7以降であれば、TSM形式でエクスポートされたモジュールをESMでインポートすることが可能 です。TypeScript 2.7では compilerOptions.esModuleInterop オプションが提供され、 true にすることでこれを可能にします(デフォルトは true )。

compilerOptions.esModuleInteropがtrueの場合
// とあるライブラリ.ts
export = FooClass;

// アプリケーション内
import FooClass from "とあるライブラリ";

一部の環境ではTypeScript 1.8で追加された compilerOptions.allowSyntheticDefaultImportstrue にすることで、TSMでエクスポートされた値をESMでインポートすることができるかもしれませんが、 esModuleInterop オプションとは異なり、 allowSyntheticDefaultImports はトランスパイル結果に一切の影響を及ぼさず、TypeScriptのチェックレイヤを通過するだけのものでした。

esModuleInterop はESMの仕様に沿ったコードになるようトランスパイルするため出力結果に影響を与えますが、 allowSyntheticDefaultImports のように表面上でだけエラーを隠すわけではないため、ランタイムエラーを引き起こすような心配はありません。
なお esModuleInterop が有効の場合は自動的に allowSyntheticDefaultImports が有効となります。

7. パッケージは「TypeScript製 > 型定義ファイル同梱有無 > @types 提供有無」の優先順位で選ぶ

アプリケーションを開発する上でライブラリの選定は重要な要素の1つで、似たようなライブラリの中からどのライブラリを選定するかは、個人の些細な差はあれど、その多くはGitHubリポジトリのスター数やnpmのダウンロード数、コミュニティの活発度、ドキュメントの豊富さなどが共通することでしょう。 TypeScriptを用いた開発においては、これに加えて“TypeScript製であるかどうか”も重要な指標の1つ となります。

スター数などの他の指標が似たりよったりするライブラリが複数ある場合、僕は以下のような優先度で最終的にライブラリを選定します。

  1. TypeScriptで記述されたライブラリか?
  2. 型定義ファイルがリポジトリに同梱されているライブラリか?
  3. @types/* で型定義が提供されているライブラリか?

「どれも型定義が提供されているのならば問題ないのでは」と思う人もいるかもしれませんが、ライブラリ本体がTypeScriptで記述されていないということは、本体の型と型定義ファイルの型に乖離が存在する可能性が高くなります。

7-1. TypeScriptで記述されたライブラリか?

ライブラリ本体がTypeScriptで記述されている場合、型定義ファイルは tsc などによって自動的に生成されたものが多く、最も信頼性の高い型定義ファイルを提供してくれます。ライブラリ内のコードが as によるType Assertionsを多用していなければ、型定義と実際のAPIに乖離が生まれることはほとんどありません。

7-2. 型定義ファイルがリポジトリに同梱されているライブラリか?

本体がJavaScriptで記述されていても、型定義ファイルがリポジトリに同梱されている場合、新バージョンリリース時に型定義ファイルも一緒に更新される可能性が高くなります。ミスとして型定義と実際のAPIに乖離が生まれることがあっても、「型定義が実際のAPIに追いついていない」という可能性は低くなります。

7-3. @types/* で型定義が提供されているライブラリか?

@types/* による型定義が提供されているライブラリの場合、型定義ファイルとライブラリ本体は別々のリポジトリに存在するため、基本的に型定義ファイルの更新が本体よりも遅れます。したがって最新のライブラリを利用する際に、型定義ファイルだけ古いAPIで記述されている……なんてこともあり得ます。

さらに @types/* は独立した1つのパッケージとして提供されているため、ライブラリ本体のバージョンと一致しないことがほとんどです。 foo というパッケージの最新バージョンが 1.2.0 でも、 @types/foo の最新バージョンが 2.3.1 などとかけ離れた場合もよくあり、本体のバージョンに対応した型定義ファイルのバージョンが何かを把握しなければいけません。

型定義ファイルの存在しないパッケージを選ぶのはできる限り控え、また @types/* の型定義ファイルの利用も本来ならば避けておきたいところです。
事実、React( react パッケージ)はHooks APIという最新のAPIがバージョン16.7で提供される予定だったのに対し、提供バージョンが 16.8 へ先延ばしされたことによって、 @types/react16.7 では本来存在しないHooks APIの型定義を参照できてしまう事態になっていました。エディタ上や tsc による型チェックではエラーが一切発生しないのに、ランタイムではエラーが発生してしまうという悲しい事件です。ぴえん。

8. Nullable<T> 型を定義する

JavaScriptには、虚無値として nullundefined の2つの値が存在します。この2つの値をどう使い分けるか、そもそも使い分けずにどちらかの値で統一するか、はプロジェクトによって方針が異なり、大手企業やライブラリの中でも揺れています。

両者の大きな違いは、以下が挙げられると思います。

  • null
    • 開発者が意図的に利用しなければ生まれない値
    • デフォルト引数が設定されている関数へ渡しても、 null が渡る
    • typeof 演算子が "object" を返す負の遺産とも言える仕様が存在する
  • undefined
    • 存在しないプロパティの参照時などに勝手に返される値
    • デフォルト引数が設定されている関数へ渡しても、デフォルト値が適用される
    • undefined 自体はグローバルオブジェクトの undefined プロパティを参照しており、ES5.0以前(Strict Mode非利用時)はグローバルオブジェクトの undefined プロパティを書き換えられてしまう仕様だった

大きく分けて undefined 統一派と使い分ける派の2つに別れますが、やはりどちらもメリット・デメリットが存在します。しかし開発する上で重要なのは、良し悪しよりもルールを決めておくこと です。僕は経験的に null undefined を使い分けることが多いです。

// `null` `undefined` 使い分ける派
type Nullable<T> = T | undefined;

// `null` `undefined` 統一派
type Nullable<T> = T | null | undefined;

const getUserName = (user: Nullable<userModel.UserModel>) => ...;

変数や関数の引数などで虚無値を受け入れる場合、 Nullable<T> 型を定義しておくと便利 です。
TypeScriptは null 及び undefined を除去する NonNullable<T> 型を標準で提供していますが、 Nullable<T> 型を提供していません。この理由がまさに上述したとおりで、プロジェクトによって方針が異なるからです5

8-1. 虚無値は undefined で統一する派 ─ TypeScript開発チーム

TypeScript自体の開発チームが用意したスタイルガイドの中には、“ nullundefined を区別せず、虚無値は undefined を利用しなければならない”と記述されています6

あくまでこのスタイルガイドはTypeScriptという言語を開発する際においてのガイドであり、TypeScript利用者へそれを強制/推奨するものでもありません。この点を勘違いしてIssueをたてる人が多かったせいか、TypeScript開発チームはブチギレて、当該ドキュメントのページ最上部には、恐ろしい数の注意文言が記載されています。

image.png

Coding guidelines · microsoft/TypeScript Wiki

「注意文言なし→1行だけ注意文言が記載される→画像のように大量の注意文言が記載される」という変遷を見てきた僕からすると、ちょっとした恐怖を感じます。

8-2. 虚無値は null undefined で使い分ける派 ─ Facebook

一方で、Reactなどのライブラリを手掛けるFacebookの開発チームは、 nullundefined の利用を明確に分けています。

現にReactでは、DOMノードを一切レンダリングさせないコンポーネントを定義する場合は、 undefined ではなく null を返さなければいけません。

何もレンダリングさせないReactコンポーネント
export const NoRendering = () => {
  ...

  return null; // `undefined` だとエラー
};

9. Optional Parametersと Nullable<T> 型を使い分ける

上記の Nullable<T> 型宣言に関連して、TypeScriptにはOptional Parameters ? が存在します。オブジェクト型のプロパティや関数の引数に対して利用できるものです。このOptional Parametersのユースケースを誤解しているプロジェクトも少なくないのですが、 Optional Parametersは「虚無値も受け取るための構文」ではなく「値の省略を許可する構文」 です。

// これらの関数はまったくコンテキストもAPIも異なるが、
// 仮引数 `b` 及び `c` の型は `string | undefined` で一致する
const func1(a: string, b: string | undefined, c: string | undefined) => ...;
const func2(a: string, b?: string, c?: string) => ...;

// Nullable( T | undefined )は「虚無値の受け渡し」を許す
func1("a", undefined, undefined);
func1("a"); // エラー

// Optional Parametersは「値の省略」を許す
func2("a", undefined, undefined);
func2("a"); // OK

JavaScriptレイヤーで見ても、オブジェクトのプロパティも関数の引数も明示的に指定しなければ、値は undefined になるため、Optional Parametersを付けた値は T | undefined 型になります。 null が入ることはありません。

型安全の点から、NullableとOptional Parametersは使い分けることをオススメします。むしろ、Optional Parametersの利用をできる限り控えたほうが良いと言っても過言ではありません。引数の追加時などに、影響範囲が浮き彫りにならないからです。
Reactでコンポーネントを定義する場合は、Propsに対しOptional Parametersを利用すべきかどうかを吟味する必要があります。

ReactでProps型を定義する場合の例(with-JSDoc)
type Props = {
  /** 表示したいユーザ、存在しなければそれ用のメッセージを表示 */
  account: Nullable<userModel.UserModel>;
  /**
   *  無効化するかいなか
   *  @default false
   */
  isDisabled?: boolean;
};

10. any でなくとも、型安全性のない型を利用しない

TypeScriptはなんと言っても型安全性を担保してくれる言語であるため、TypeScript初学者向けの記事や本でも「 any はできる限り控えるべき」と記述されていることが多く、これに異論を唱える人はいないでしょう。しかし any 以外にも型安全性のない型はいくつか存在します。

言語自体の解説記事ではないのでできる限り詳細は割愛したいところですが、特に初心者が引っかかりやすいのは {} 型でしょう。 {} 型は空オブジェクトにのみ一致する型ではなく、虚無値を除くすべての型に一致する安全性の低い型 です。

TypeScriptには、記述した値そのものが型になるLiteral Typesのおかげで、とても柔軟な型定義が可能になっています。

Literal-Types
type A = "sample";

const a: A = "foo";
     ^^^
// Type '"foo"' is not assignable to type '"sample"'.ts(2322)

しかしオブジェクト型の定義には注意が必要です。 TypeScriptは構造的部分型を採用しています 。また JavaScriptは「ほぼすべてがオブジェクトのように振る舞う」性質を持ちます 。この2つの特性によって、オブジェクト型の挙動は、直感と反するかもしれません。

10-1. JavaScriptの「ほぼすべてがオブジェクトのように振る舞う」性質

Rubyなどとは異なり、JavaScriptはすべてがオブジェクトの言語ではありません。プリミティブ値を持つ numberstring 型が存在します。しかしながら、これらのプリミティブ値に対して、対応する標準オブジェクトのプロパティを参照することができます。その際に、プリミティブ値は対応する標準オブジェクトへ一時的に変換され、評価終了後にプリミティブ値へ戻ります。

const num1 = 123;         // これは number型 のプリミティブ値
const num2 = Number(123); // これは number型 に対応する標準オブジェクト

num2.toString(); // "123" -- 標準オブジェクトのプロパティ(の中の関数)を参照
num1.toString(); // "123" -- プリミティブ値→標準オブジェクトへ変換→プロパティ参照→評価後にプリミティブ値へ戻る

すなわちJavaScriptは、プリミティブ値であっても対応する標準オブジェクトのプロパティを呼ぶことができるため、「オブジェクトのように振る舞う」言語です。
しかし、対応する標準オブジェクトが存在しないプリミティブ値もあります。それが虚無値である undefinednull です。したがって「ほぼ」がつく、「JavaScriptは、ほぼすべてがオブジェクトのように振る舞う」と説明できるわけです。

10-2. TypeScriptの構造的部分型がもたらす罠

一方でTypeScriptは構造的部分型であるため、構造が部分一致していれば同じ型であるとみなします。例えばStringオブジェクトもNumberオブジェクトも、プロトタイプに toString プロパティを持っているため、以下の型はどちらのオブジェクトにも一致します。

オブジェクトが共通プロパティを持っていれば、同じ型としてみなす
type StringConvertible = {
  toString: () => void;
};

const a: StringConvertible = Number(123);
const b: StringConvertible = String("123");

しかし先程の「一部を除くプリミティブ値はオブジェクトのように振る舞う」という性質を持っているため、上記の型はプリミティブ値にも一致することになります。

プリミティブ値であっても変換後の対応オブジェクトに対して部分型が適用される
type StringConvertible = {
  toString: () => void;
};

const a: StringConvertible = 123;
const b: StringConvertible = "123";

ここまで読めばお分かりだと思いますが、 {} 型というのは、一見空オブジェクトと一致する型のように見えて、オブジェクトのように振る舞うすべての値=虚無値以外のすべての値と一致する、ほぼほぼ any 型と変わらない型 であるわけです。

type EmptyObject = {};

// 以下はどれもエラーにならない
const a: EmptyObject = 123;
const b: EmptyObject = "123";
const c: EmptyObject = false;
const d: EmptyObject = [];
const e: EmptyObject = {};

// 以下はどれもエラーになる
const f: EmptyObject = null;
const g: EmptyObject = undefined;

type XXX = {} のように直接 {} を型定義に利用することはないと思いますが、Generics周りでは注意が必要です。経験上、無意識に Foo<{}> などと渡している箇所が多々見られたからです。

11. エディタのリネーム機能を活用する

image.png

TypeScriptはLanguage Serviceと呼ばれるサービスによって、異なるエディタでも似たようなコード解析やコードジャンプが行えるようになっています。(僕はLanguage Serviceに詳しいわけではありませんが)特にTypeScriptのLanguage Serviceはとても優秀で、バグを見たことがほとんどありません。

TypeScriptとVS CodeのようなIDEライクなエディタを利用する上で活用しておきたいのは、リネーム機能です。VS Codeの場合 F2 キーでリネーム機能を呼び出せますが、利用頻度の割には遠い位置にキーマップされているため、僕は Ctrl+R に割り当てています。

// オブジェクト型のプロパティをリネームすると……
export type UserModel = {
-   email: string;
+   emailAddress: string;
};

// 型参照を正規化していても、リネーム対象となる
type Props = {
-   email: userModel.UserModel["email"];
+   email: userModel.UserModel["emailAddress"];
};

TypeScriptにおけるリネーム機能は、リネーム対象となる変数をモジュール(ESM/CommonJS)経由で参照しているファイル内からすべてを網羅して一括リネームしてくれるため、わざわざgrepして影響箇所を調べる必要がありません。さらに、変数や関数などの値だけでなく、Type Aliasesやオブジェクト型のプロパティ名、Enumのキー名などもリネーム対象となるため、リファクタリングが非常に捗ります。

最後に

TypeScriptはJavaScriptあっての言語です。HTMLやCSSも同様ですが、JavaScriptは破壊的変更が許されない(言語設計者からすると)シビアな言語であり、それ故にバグすらも仕様として扱われます。TypeScriptの罠のほとんどはJavaScriptの罠から生まれたものであり、逆に言えばJavaScriptの特性や罠さえ理解していれば、TypeScriptの罠を理解することも容易いことでしょう。

本記事ではあくまでアプリケーション上で利用する際のTipsを述べたまでで、TypeScript自体の詳細な機能は述べていません。しかしTypeScriptの型プログラミングの柔軟性や、開発の安定性はとても素晴らしいものです。僕もまだまだ学習中の身ではありますが、JavaScriptもTypeScriptも日々進化しているため、表面上の情報だけでなく、実際にプロダクトに落とし込むことが可能な機能なのか、どのような経緯で実装された機能なのかも理解していきたいですね。

おひたしおひたし。


  1. 上級言語をバイナリへ変換することを“コンパイル”と言いますが、TypeScriptのようなメタ言語を上級言語へ変換することを“トランスパイル”と言います。しかし言い分ける必要もないため、多くの場合は“コンパイル”と表記が統一されることもあります。 

  2. Modules/Meta - CommonJS Spec Wiki 

  3. ECMAScript 2015 Language Specification – ECMA-262 6th Edition 

  4. ECMAScript 2015 Language Specification – ECMA-262 6th Edition 

  5. Nullable types on TypeScript - aka Maybe Types from Flow · Issue #23477 · microsoft/TypeScript 

  6. Coding guidelines · microsoft/TypeScript Wiki 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした