tsconfig.json Deep Dive

基本的なtsconfig.jsonの設定を理解されている前提で話が進みますのでまだの方はすでにある本書のtsconfig.jsonのページをご覧ください。

パッケージを使う人にもTypeScriptによる型の享受を目指す

パッケージを公開する時は、動作する形で公開するのが前提なのでjsにする必要があります。つまりトランスパイルは必須です。ですがせっかくTypeScriptで作ったのだからパッケージの型情報も提供しましょう。

型定義ファイルも出力する

型定義ファイルを一緒に出力しましょう。そのためにはtsconfig.jsonにあるdeclarationの項目をtrueに変更します。

"declaration": true,
/* Generates corresponding '.d.ts' file. */

このように設定するとトランスパイルで出力したjsファイルと同じディレクトリに同名で拡張子がd.tsのファイルも出力されるようになります。これが型情報のファイルです。なおこの型定義ファイルだけをトランスパイルで出力されたjsと別のディレクトリに出力するためのオプションは存在しません。

変哲もないnumberを持つValue Objectを作ったとします。

class NumericalValueObject {
private value: number;
public constructor(value: number) {
this.value = value;
}
public eq(other: NumericalValueObject): boolean {
return this.value === other.value;
}
public gt(other: NumericalValueObject): boolean {
return this.value > other.value;
}
public gte(other: NumericalValueObject): boolean {
return this.value >= other.value;
}
public lt(other: NumericalValueObject): boolean {
return this.value < other.value;
}
public lte(other: NumericalValueObject): boolean {
return this.value <= other.value;
}
public toString(): string {
return `${this.value}`;
}
}

これをトランスパイルし、型定義を生成するとこのようになっています。

declare class NumericalValueObject {
private value;
constructor(value: number);
eq(other: NumericalValueObject): boolean;
gt(other: NumericalValueObject): boolean;
gte(other: NumericalValueObject): boolean;
lt(other: NumericalValueObject): boolean;
lte(other: NumericalValueObject): boolean;
toString(): string;
}

内容自体は実装部分がないインターフェースのようなファイルとなっています。

宣言元へのジャンプでのtsファイルを参照できるようにする

IDEを使っている時に有用で、実際のtsのソースコードがどのようにして動作しているのかを閲覧することができるようになります。tsconfig.jsonにあるdeclarationMapの項目をtrueに変更します。

"declarationMap": true,
/* Generates a sourcemap for each corresponding '.d.ts' file. */

このように設定するとトランスパイルで出力したjsファイルと同じディレクトリに同名で拡張子がd.ts.mapのファイルも出力されるようになります。このファイルは元のtsと実際に動作するjsの対応付けをしてくれます。ただしこの設定だけでは不十分で、参照元となる元のtsファイルも一緒にパッケージとして公開する必要があります。

元のtsも公開する

特に設定していなければ元のtsファイルも公開されますが、公開する内容を調整している場合は逆にpackage.jsonfilesプロパティをを変更して元のtsファイルも公開するように変更が必要です。tsconfig.jsondeclarationMapを設定しても元のtsファイルを参照できない時はここで公開する内容を制限していないか確認してください。

{
"name": "YYTS",
"version": "1.0.0",
"license": "CC BY-SA 3.0",
"sideEffects": false,
"main": "./cjs/index.js",
"module": "./esm/index.js",
"types": "./esm/index.d.ts",
"files": [
"dist",
"src"
],
"scripts": {
"build": "yarn build:cjs && yarn build:esm",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json"
}
}

この例はdistにトランスパイルした結果のjs, d.ts, d.ts.mapがあり、srcに元のtsがあるものと想定しています。

JavaScriptのsourceMapも出力する

sourceMapとはAltJSがトランスパイルされたJavaScriptとの行を一致させるものです。これがあることによってデバッグやトレースをしている時に、元のtsファイルの何行目で問題が発生しているかわかりやすくなります。module bundlerを使用する時はこのオプションを有効にしていないと基本的に何もわかりません。

tsconfig.jsonにあるsourceMapの項目をtrueに変更します。

"sourceMap": true,
/* Generates corresponding '.map' file. */

こちらもトランスパイルで出力したjsファイルと同じディレクトリに同名で拡張子がjs.mapのファイルも出力されるようになります。

Dual Package

フロントエンドでもバックエンドでもTypeScriptこれ一本!Universal JSという考えがあります。確かにフロントエンドを動的にしたいのであればほぼ避けて通れないJavaScriptと、バックエンドでも使えるようになったJavaScriptで同じコードを使いまわせれば保守の観点でも異なる言語を触る必要がなくなり、統一言語としての価値が大いにあります。

しかしながらフロントエンドとバックエンドではJavaScriptのモジュール解決の方法が異なります。この差異のために同じTypeScriptのコードを別々に分けなければいけないかというとそうではありません。ひとつのモジュールをcommonjs, esmoduleの両方に対応した出力をするDual Packageという考えがあります。

Dual Packageことはじめ

名前が仰々しいですが、やることはcommonjs用のJavaScriptとesmodule用のJavaScriptを出力することです。つまり出力するmoduleの分だけtsconfig.jsonを用意します。

プロジェクトはおおよそ以下のような構成になります。

./
├── tsconfig.base.json
├── tsconfig.cjs.json
├── tsconfig.esm.json
└── tsconfig.json
  • tsconfig.base.json

    • 基本となるtsconfig.jsonです

  • tsconfig.cjs.json

    • tsconfig.base.jsonを継承したcommonjs用のtsconfig.jsonです

  • tsconfig.esm.json

    • tsconfig.base.jsonを継承したesmodule用のtsconfig.jsonです

  • tsconfig.json

    • IDEなどはこの名前を優先して探すので、そのためのtsconfig.jsonです

tsconfig.base.jsontsconfig.jsonを分けるかどうかについては好みの範疇です。まとめてしまっても特に問題はありません。

tsconfig.jsonの継承

tsconfig.jsonは他のtsconfig.jsonを継承する機能があります。上記はtsconfig.cjs.json, tsconfig.esm.jsonは以下のようにしてtsconfig.base.jsonを継承しています。

// tsconfig.cjs.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist/cjs",
// ...
}
}
// tsconfig.esm.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "esnext",
"outDir": "./dist/esm",
// ...
}
}

outDirはトランスパイルしたjsと、型定義ファイルを出力していれば(後述)それを出力するディレクトリを変更するオプションです。

このようなtsconfig.xxx.jsonができていれば、あとは以下のようにファイル指定してトランスパイルをするだけです。

tsc -p tsconfig.cjs.json
tsc -p tsconfig.esm.json

Dual Pakcageのためのpackage.json

package.jsonDual Packageのための設定が必要です。

main

package.jsonにあるそのパッケージのエントリポイントとなるファイルを指定する項目です。Dual Packageの時はここにcommonjsのエントリポイントとなるjsファイルを設定します。

module

Dual Packageの時はここにesmoduleのエントリポイントとなるjsファイルを設定します。

types

型定義ファイルのエントリポイントとなるtsファイルを設定します。型定義ファイルを出力するようにしていればcommonjs, esmoduleのどちらのtsconfig.jsonで出力したものでも問題ありません。

package.jsonはこのようになっているでしょう。

{
"name": "YYTS",
"version": "1.0.0",
"license": "CC BY-SA 3.0",
"main": "./cjs/index.js",
"module": "./esm/index.js",
"types": "./esm/index.d.ts",
"scripts": {
"build": "yarn build:cjs && yarn build:esm",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json"
}
}

トランスパイル後のjsのファイルの出力先はあくまでも例です。tsconfig.jsonoutDirを変更すれば出力先を変更できるのでそちらを設定後、package.jsonでエントリポイントとなるjsファイルの設定をしてください。

Tree Shaking

module bundlerの登場により、フロントエンドは今までのような<script>でいろいろなjsファイルを読み込む方式に加えてを全部載せjsにしてしまうという選択肢が増えました。この全部載せjsは開発者としては自分ができる全てをそのまま実行環境であるブラウザに持っていけるので楽になる一方、ひとつのjsファイルの容量が大きくなりすぎるという弱点があります。特にそれがSPA(Signle Page Application)だと問題です。SPAは読み込みが完了してから動作するのでユーザーにしばらく何もない画面を見せることになってしまいます。

この事態を避けるためにmodule bundlerは容量削減のための涙ぐましい努力を続けています。その機能のひとつとして題名のTree Shakingを紹介するとともに、開発者にできるTree Shaking対応パッケージの作り方を紹介します。

Tree Shakingとは

Tree Shakingとは使われていない関数、クラスを最終的なjsファイルに含めない機能のことです。使っていないのであれば入れる必要はない。というのは至極当然の結論ですが、このTree Shakingを使うための条件があります。

  • esmoduleで書かれている

  • 副作用(side effects)のないコードである

各条件の詳細を見ていきましょう。

esmoduleで書かれている

commonjsesmoduleでは外部ファイルの解決方法が異なります。

commonjsrequire()を使用します。require()はファイルのどの行でも使用ができますがesmoduleimportはファイルの先頭でやらなければならないという決定的な違いがあります。

require()はある時はこのjsを、それ以外の時はあのjsを、と読み込むファイルをコードで切り替えることができます。つまり、以下のようなことができます。

let police = null;
let firefighter = null;
if (shouldCallPolice()) {
police = require('./police');
} else {
firefighter = require('./firefighter');
}

一方、先述の通りesmoduleはコードに読み込みロジックを混ぜることはできません。

上記例でshouldCallPolice()が常にtrueを返すように作られていたとしてもmodule bundlerはそれを検知できない可能性があります。本来なら必要のないfirefighterを読み込まないという選択を取ることは難しいでしょう。

最近ではcommonjsでもTree Shakingができるmodule bundlerも登場しています。

副作用(side effects)のないコードである

ここで言及している副作用とは以下が挙げられます。

  • exportするだけで効果がある

  • プロトタイプ汚染のような、既存のものに対して影響を及ぼす

これらが含まれているかもしれないとmodule bundlerが判断するとTree Shakingの効率が落ちます。

副作用がないことを伝える

module bundlerに制作したパッケージに副作用がないことを伝える方法があります。package.jsonにひとつ加えるだけで完了します。

sideEffects

このプロパティをpackage.jsonに加えて、値をfalseとすればそのパッケージには副作用がないことを伝えられます。

{
"name": "YYTS",
"version": "1.0.0",
"license": "CC BY-SA 3.0",
"sideEffects": false,
"main": "./cjs/index.js",
"module": "./esm/index.js",
"types": "./esm/index.d.ts",
"scripts": {
"build": "yarn build:cjs && yarn build:esm",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json"
}
}

副作用があり、そのファイルが判明している時はそのファイルを指定します。

{
"name": "YYTS",
"version": "1.0.0",
"license": "CC BY-SA 3.0",
"sideEffects": [
"./xxx.js",
"./yyy.js"
],
"main": "./cjs/index.js",
"module": "./esm/index.js",
"types": "./esm/index.d.ts",
"scripts": {
"build": "yarn build:cjs && yarn build:esm",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json"
}
}

より厳密にコーディングする

厳密なコーディングといえばlinterがあります。TypeScript自身にもより型チェックを厳密にするオプションがあります。以下はtsconfig.jsonの該当する部分を抜粋したものです。

/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */

初期設定ではstrictのみが有効になっています。

以下は各オプションの説明です。

strict

このオプションはTypeScript4.0時点で以下の7個のオプションを全て有効にしていることと同じです。スクラッチから開発するのであれば有効にしておいて差し支えないでしょう。

  • noImplicitAny

  • strictNullChecks

  • strictFunctionTypes

  • strictBindCallApply

  • strictPropertyInitialization

  • noImplicitThis

  • alwaysStrict

この説明にTypeScriptのバージョンが明記されているのは、今後のバージョンでオプションが追加または廃止されることがありうるからです。より安定したオプションを設定したい場合はstrictではなく個々のオプションを有効にしてください。

noImplicitAny

型を明示しない引数はTypeScriptではanyになりますが、これを禁止します。

function increment(i) {
return i + 1;
};

noImplicitAnytrueに設定しこれをトランスパイルしようとすると

Parameter 'i' implicitly has an 'any' type.

となります。これを回避するためには

function increment(i: number): number {
return i + 1;
};

とします。なお戻り値の型は必須ではありません。

strictNullChecks

null, undefinedのチェックが厳密になります。このオプションを入れていると変数に代入する時にnull, undefinedの代入を防げます。

const error: Error = null;

strictNullCheckstrueに設定しこれをトランスパイルしようとすると

Type 'null' is not assignable to type 'Error'.

となります。これを回避するためには

const error: Error | null = null;

とします。

strictFunctionTypes

関数の引数の型チェックが厳格になります。

class RuntimeError extends Error {
private cause?: Error;
public constructor(message: string, cause?: Error) {
super(message);
this.cause = cause;
}
public stacktrace(): string | undefined {
if (typeof this.cause === 'undefined') {
return this.stack;
}
return this.cause.stack;
}
}

Errorを拡張して他のErrorを内包できるようにしたRuntimeErrorがあります。このクラスにはstacktrace()というメソッドがあります。

スタックトレースを出力するメソッドruntimeDump()を作成します。

const runtimeDump = (err: RuntimeError): void => {
console.log(err.stacktrace());
};

もちろん以下は動作します。

runtimeDump(new RuntimeError('runtime error', new Error('error')));

runtimeDump()Error型を引数に受けるType aliasに代入します。

type ErrorDump = (err: Error) => void;
const dump: ErrorDump = runtimeDump;

代入したdump()はもちろん引数にError型を受けることができます。

dump(new Error('error'));

しかしながら、これは落ちてしまいます。

TypeError: err.stacktrace is not a function

これはErrorにはerr.stacktrace()が存在しないことが原因です。

strictFunctionTypestrueに設定しこれをトランスパイルしようとすると

Type '(err: RuntimeError) => void' is not assignable to type 'ErrorDump'.
Types of parameters 'err' and 'err' are incompatible.
Property 'stacktrace' is missing in type 'Error' but required in type 'RuntimeError'.

となります。関数の引数における型は、このオプションを有効にすることで代入時にそのクラスまたはサブクラス以外を禁止します。

strictBindCallApply

function.bind(), function.call(), function.apply()の型チェックが厳密になります。

function stackTrace(error: Error): string | undefined {
return error.stack;
}
stackTrace.call(undefined, null);

strictBindCallApplytrueに設定しこれをトランスパイルしようとすると

Argument of type 'null' is not assignable to parameter of type 'Error'.

となります。これを回避するためにはcall()の第2引数をErrorのインスタンスに変更します。

stackTrace.call(undefined, new ReferenceError());

strictPropertyInitialization

初期化されていない変数定数や、コンストラクタで初期化されていないクラスのプロパティを禁止します。

class User {
public name: string;
public gender: string;
public age: number;
}

strictPropertyInitializationtrueに設定しこれをトランスパイルしようとすると

Property 'name' has no initializer and is not definitely assigned in the constructor.
Property 'gender' has no initializer and is not definitely assigned in the constructor.
Property 'age' has no initializer and is not definitely assigned in the constructor.

となります。これを回避するためにはコンストラクタで初期化するか、初期値を設定します。

class User {
public name: string;
public gender: string;
public age: number;
public constructor(name: string, gender: string, age: number) {
this.name = name;
this.gender = gender;
this.age = age;
}
}
class User {
public name: string = 'John';
public gender: string = 'Female';
public age: number = 20;
}

このオプションが有効だと、ORMで使うようなプロパティが全てpublicでコンストラクタのないクラスは基本的に作れなくなります。

noImplicitThis

名前付き関数、匿名関数はアロー関数と異なり、実行時にthisが決定されます。そのため、内部でthisを使っているとそれは関数を書いている時点ではanyと同じ扱いになります。このオプションはそれを禁止します。

以下のようなType aliasがあるとします。

type Person = {
name01: string;
name02: string;
name03: string;
name04: string;
name05: string;
name06: string;
name07: string;
name08: string;
name09: string;
name10: string;
name11: string;
name12: string;
name13: string;
name14: string;
name15: string;
name16: string;
name17: string;
name18: string;
name19: string;
name20: string;
intro(): void;
};

そして、アルファベットの先頭1文字を大文字にするcapitalize()nameXXのプロパティを出力するdump()を定義します。

function capitalize(str: string): string {
return `${str[0].toUpperCase()}${str.slice(1)}`;
}
function dump(): string {
const props: string[] = [];
props.push(capitalize(this.name01));
props.push(capitalize(this.name02));
props.push(capitalize(this.name03));
props.push(capitalize(this.name04));
props.push(capitalize(this.name05));
props.push(capitalize(this.name06));
props.push(capitalize(this.name07));
props.push(capitalize(this.name08));
props.push(capitalize(this.name09));
props.push(capitalize(this.name10));
props.push(capitalize(this.name11));
props.push(capitalize(this.name12));
props.push(capitalize(this.name13));
props.push(capitalize(this.name14));
props.push(capitalize(this.name15));
props.push(capitalize(this.name16));
props.push(capitalize(this.name17));
props.push(capitalize(this.name18));
props.push(capitalize(this.name19));
props.push(capitalize(this.name20));
props.push(capitalize(this.name21));
props.push(capitalize(this.name22));
props.push(capitalize(this.name23));
props.push(capitalize(this.name24));
return props.join(' ');
}

これを使いPersonのインスタンスを作成します。

const person: Person = {
name01: 'pablo',
name02: 'diego',
name03: 'josé',
name04: 'francisco',
name05: 'de',
name06: 'paula',
name07: 'juan',
name08: 'nepomuceno',
name09: 'maría',
name10: 'de',
name11: 'los',
name12: 'remedios',
name13: 'cipriano',
name14: 'de',
name15: 'la',
name16: 'santísima',
name17: 'trinidad',
name18: 'ruiz',
name19: 'y',
name20: 'picasso',
intro: dump
};

トランスパイルして実行します。

console.log(person.intro());

落ちます。

TypeError: Cannot read property '0' of undefined

これはプロパティがname20までしかないPersonのオブジェクトリテラルに対してname21 ~ name24のプロパティを取得し、それにcapitalize()を適用しようとしたことが問題です。

noImplicitThistrueに設定しこれをトランスパイルしようとすると大量の以下の警告が表示されます。

'this' implicitly has type 'any' because it does not have a type annotation.

これを回避するためにはdump()の関数が扱っているthisが何かを指定します。dump()の第1引数をthisとし、その型を書くことでTypeScriptに伝えることができます。

function dump(this: Person): string {
// ...
}

するとTypeScriptは存在しないプロパティについての指摘をするようになります。name21 ~ name30に以下の警告が出るようになります。

Property 'nameXX' does not exist on type 'Person'. Did you mean 'name01'?

この引数のthisについては関数 (Functions)の頁に詳細がありますので併せてご参照ください。

alwaysStrict

'use strict'を各ファイルの先頭に付加します。

noUnusedLocals

使用していない変数を禁止します。

function add(n1: string, n2: string): number {
const str: string = 'this is debug message';
// debug(str);
return n1 + n2;
}

noUnusedLocalstrueに設定しこれをトランスパイルしようとすると

'str' is declared but its value is never read.

となります。デバッグをしている時など若干邪魔な時があります。

noUnusedParameters

使用していない引数を禁止します。

function choose(n1: string, n2: string): number {
return n2;
}

noUnusedParameterstrueに設定しこれをトランスパイルしようとすると

'n1' is declared but its value is never read.

となります。これは上記例のように第2引数だけを使用する関数に対しても適用されます。これを回避するためには、使用していない引数を_で始まる名前に変更します。

function choose(_n1: string, n2: string): number {
// ...
}

noImplicitReturns

関数の全ての条件分岐でreturnが行われているかを厳密にチェックします。

function negaposi(num: number): string {
if (num > 0) {
return 'positive';
} else if (num < 0) {
return 'negative';
}
}

noImplicitReturnstrueに設定しこれをトランスパイルしようとすると

Not all code paths return a value.

となります。これを回避するためには戻り値がvoid以外の関数は常に最後にreturnを書くようにするか、場合分けを漏れないように設計するしかありません。

function negaposi(num: number): string {
if (num > 0) {
return 'negative';
} else if (num < 0) {
return 'positive';
}
return '0';
}

noFallthroughCasesInSwitch

fallthroughとはswitchbreakまたはreturnを行わないことを意味します。以下は多くの方がswitchで学習したであろうfallthoughの例です。

function daysOfMonth(month: number): number {
switch (month) {
case 1:
case 3:
case 5:
case 7:
case 8:
case 10:
case 12:
return 31;
case 2:
return 28;
case 4:
case 6:
case 9:
case 11:
return 30;
default:
throw new Error('INVALID INPUT');
}
}

意図してこのfallthroughを使いこなすよりもバグを産むことの方が遥かに多いため、このオプションはそれを禁止します。

function nextLyric(lyric: string, count: number): string {
switch (lyric) {
case 'we':
return 'will';
case 'will':
if (count === 1) {
return 'we';
}
if (count === 2) {
return 'rock';
}
case 'rock':
return 'you';
default:
throw new Error('YOU ARE A KING!!!');
}
}

noFallthroughCasesInSwitchtrueに設定しこれをトランスパイルしようとすると

Fallthrough case in switch.

となります。これはcase 'will'の時にreturnされない場合がある、つまりfallthroughが発生していることが問題です。

これを回避するためにはcaseでは漏れなくbreakあるいはreturnをするように設計します。

function next(lyric: string, count: number): string {
switch (lyric) {
case 'we':
return 'will';
case 'will':
if (count % 2 === 1) {
return 'we';
}
return 'rock';
case 'rock':
return 'you';
default:
throw new Error('YOU ARE A KING!!!');
}
}

なお、このオプションはcaseに処理がある場合のみbreakあるいはreturnを強制します。この項目で一番初めに紹介した一か月の日数を求める関数daysOfMonth()は、fallthroughであるcaseは全て処理がないため警告は発生しません。