30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript の esModuleInterop フラグについて

esModuleInteropフラグを有効にすると、コンパイル時にヘルパーメソッドが生成されるようになり、モジュールシステムの相互運用性が高まる。
これにより、defaultをエクスポートしていない CommonJS 形式のモジュールを、ES Modules でデフォルトインポートする、といったことが可能になる。

この記事の内容は、TypeScript のv3.9.6と Node.js のv12.17.0で動作確認している。

ES Modules と CommonJS の互換性

まず、検証用に以下のファイルを作成する。

// src/myModule.js
const add = (a, b) => a + b;

module.exports = {
  value: 123,
  add,
};
// src/index.ts
import {value, add} from './myModule';

console.log(value);
console.log(add(3, 4));

src/myModule.jsは CommonJS 形式でエクスポートしており、それをsrc/index.tsでインポートして使っている。

そして、tsconfig.jsonを以下の内容にした上で、コンパイルする。

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "CommonJS",
    "outDir": "./dist",
    "strict": true,
    "noImplicitAny": false,
    "moduleResolution": "Node",
    "allowJs": true
  },
  "include": ["./src/**/*"]
}

そうするとdist/index.jsが生成されるので、実行してみる。

$ node dist/index.js
123
7

無事に実行される。

インポートに*を使っても、問題ない。

import * as myModule from './myModule';

console.log(myModule.value);
console.log(myModule.add(3, 4));

問題は、デフォルトインポートしたときである。

// can only be default-imported using the 'esModuleInterop' flagts(1259)
import myModule from './myModule';

console.log(myModule.value);
console.log(myModule.add(3, 4));

コンパイルエラーになってしまう。
以下のようにdefaultをエクスポートしていればそれを読み込めるが、CommonJS ではこの書き方はあまり一般的ではない。

module.exports = {
  default: 1,
};

例えば、@types/nodeパッケージをインストールすることで、 Node.js の標準モジュールを使ったコードを書けるようになるが、以下のコードはエラーになる。

// Module '"fs"' has no default export.ts(1192)
import fs from 'fs';

console.log(fs.readFile);

サードパーティライブラリでも同様の問題が起こり得る。
例えば React で発生する。

// can only be default-imported using the 'esModuleInterop' flagts(1259)
import React from 'react';

console.log(React.memo);

allowSyntheticDefaultImports

allowSyntheticDefaultImportsフラグを有効にすることで、コンパイルエラーを回避できるようになる。

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "CommonJS",
    "outDir": "./dist",
    "strict": true,
    "noImplicitAny": false,
    "moduleResolution": "Node",
    "allowJs": true,
    "allowSyntheticDefaultImports": true // これを追加
  },
  "include": ["./src/**/*"]
}

これで、以下のコードもコンパイルできるようになった。

import fs from 'fs';

console.log(fs.readFile);

だが残念ながら、コンパイルしたコードを実行するとエラーになる。

TypeError: Cannot read property 'readFile' of undefined

allowSyntheticDefaultImportsは型チェックにのみ影響を与えるフラグで、生成される JavaScript には影響しない。
そのため、相変わらずimport fs from 'fs';fsモジュールを読み込むことはできず、fsundefinedになってしまう。

esModuleInterop

実際にデフォルトインポートするためには、esModuleInteropフラグを有効にする。
そうすることで CommonJS と相互運用可能なコードが出力されるようになり、デフォルトインポートも使えるようになる。
また、このフラグを有効にすると自動的にallowSyntheticDefaultImportsも有効になる。

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "CommonJS",
    "outDir": "./dist",
    "strict": true,
    "noImplicitAny": false,
    "moduleResolution": "Node",
    "allowJs": true,
    "esModuleInterop": true // これを追加
  },
  "include": ["./src/**/*"]
}

これで、デフォルトインポートできるようになった。

import fs from 'fs';

console.log(fs.readFile); // [Function: readFile]

生成されたdist/index.jsを見てみると、__importDefaultというヘルパーメソッドが生成されており、これによってデフォルトインポートが可能になっている。

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs"));
console.log(fs_1.default.readFile);

また、*を使ったインポート文を書くと、__importStarが生成される。

import * as fs from 'fs';

console.log(fs.readFile);
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("fs"));
console.log(fs.readFile);

参考資料

TypeScript における変性(variance)について

プログラミングの型システムに関する記事を読んでいると、共変や反変といった用語が出てくることがある。
TypeScript や Flow についての記事でも、見かけることがある。
それらは TypeScript を使う上で必須の知識ではないが、把握しておくに越したことはない。
この記事では、TypeScript を題材にして、変性について説明していく。
TypeScript に関する議論を理解できるようになることがこの記事の目的であり、より詳細な、学術的、数学的な内容には踏み込まない。

この記事の内容は、TypeScript のv3.9.5で動作確認している。

変性

変性(variance)とは、任意の型Tに対してどのような性質を持つのか示したものであり、以下の 4 種類がある。

不変性(invariance Tそのものが必要
共変性(covariance Tそのものか、そのサブタイプが必要
反変性(contravariance Tそのものか、そのスーパータイプが必要
双変性(bivariance Tそのものか、そのスーパータイプ、もしくはサブタイプが必要

これを見れば分かるように、「スーパータイプ」や「サブタイプ」といった概念と強く関連している。
そのためまず、「スーパータイプ」や「サブタイプ」といった用語について、整理しておく。
そのあとに、共変や反変の具体例を見ていく。

スーパータイプとサブタイプ

型のことを、値の集合だと考えることができる。
例えばnumberは、全ての数値の集合である。
numberという集合には、132などのあらゆる数値が含まれる。

f:id:numb_86:20200704092431p:plain:w600

このとき、numberの要素である132を、numberのサブタイプと呼ぶ。
逆に、numberのことを、132のスーパータイプと呼ぶ。

number | stringnumberstringの和集合であり、stringnumberのスーパータイプということになる。

f:id:numb_86:20200704092450p:plain:w600

型 T に対しては T のサブタイプも割り当てることができる

Tという型が求められたとき、Tだけでなく、Tのサブタイプも割り当てることができる。

そのため、number型に対して1を割り当てることができる。

let x = 1; // let x: number
const y = 1; // const y: 1

const a: number = x; // ok
const b: number = y; // ok

逆に、Tのスーパータイプを割り当てることはできない。
以下のコードでは、1という型を持つaに、1のスーパータイプであるnumberを割り当てようとしているため、エラーになる。

let x = 1; // let x: number
const y = 1; // const y: 1

const a: 1 = x; // Type 'number' is not assignable to type '1'.ts(2322)
const b: 1 = y; // ok

number | stringnumberstringのスーパータイプなので、numberstringも割り当てることができる。

type Foo = number | string;

let x = 1; // let x: number
const y = 1; // const y: 1
let z = "abc"; // let z: string

const a: Foo = x; // ok
const b: Foo = y; // ok
const c: Foo = z; // ok

オブジェクトや配列は共変

先程の例はプリミティブ型だが、オブジェクト型ではどうなるか。

オブジェクトの場合、そのプロパティが、期待される型、もしくはそのサブタイプであるときに、割り当てることができる。
それ以外の型を割り当てようとした場合、エラーになる。

type Foo = {
  x: number;
  y: 1;
};

// ok
const a: Foo = {
  x: 1,
  y: 1,
};

const b: Foo = {
  x: "1", // Type 'string' is not assignable to type 'number'.ts(2322)
  y: 1,
};

let y = 1; // let y: number

const c: Foo = {
  x: 1,
  y: y, // Type 'number' is not assignable to type '1'.ts(2322)
};

この特徴はまさに、共変性である。
そのため、「オブジェクトは、そのプロパティについて共変である」と表現できる。

冒頭の表を再掲しておく。

不変性(invariance Tそのものが必要
共変性(covariance Tそのものか、そのサブタイプが必要
反変性(contravariance Tそのものか、そのスーパータイプが必要
双変性(bivariance Tそのものか、そのスーパータイプ、もしくはサブタイプが必要

配列も、共変である。つまり、T[]に対しては、T[]だけでなく、Tのサブタイプ[]も割り当て可能である。

type Foo = (string | number)[];

const x = [1]; // const x: number[]
const y = ["1"]; // const y: string[]
const z = [true]; // const z: boolean[]

const a: Foo = x; // ok
const b: Foo = y; // ok
const c: Foo = z; // Type 'boolean' is not assignable to type 'string | number'.ts(2322)

関数の返り値は共変

最後に、関数の型について見ていく。

まず、検証用のクラスを 3 つ作成する。

class A {
  methodA() {
    console.log("A");
  }
}

class B extends A {
  methodB() {
    console.log("B");
  }
}

class C extends B {
  methodC() {
    console.log("C");
  }
}

TypeScript でクラスを定義すると同名の型が作られるが、この型は、そのクラスのインスタンスを意味する。

class A {
  methodA() {
    console.log("A");
  }
}

const x = new A(); // const x: A

そして、継承関係に基づき、ABCのスーパータイプになり、BCのスーパータイプとなる。

配列の復習も兼ねて、確認してみる。

class A {
  methodA() {
    console.log("A");
  }
}

class B extends A {
  methodB() {
    console.log("B");
  }
}

class C extends B {
  methodC() {
    console.log("C");
  }
}

type ArrayOfA = A[];
type ArrayOfB = B[];

const foo: ArrayOfA = [new A(), new B(), new C()]; // ok
const baz: ArrayOfB = [new B(), new C()]; // ok
const quz: ArrayOfB = [new A()]; // Property 'methodB' is missing in type 'A' but required in type 'B'.ts(2741)

CBのサブタイプなのでArrayOfBの要素になれるが、ABのスーパータイプなので、ArrayOfBの要素にしようとするとエラーになる。

準備が整ったので、関数の型について検証していく。

まずは返り値から。

ReturnBBを返す関数だが、Cを返す関数を割り当てることもできる。だが、Aを返す関数は、割り当てることができない。
CBのサブタイプなので、「関数は、その返り値に関して共変である」ということができる。

class A {
  methodA() {
    console.log("A");
  }
}

class B extends A {
  methodB() {
    console.log("B");
  }
}

class C extends B {
  methodC() {
    console.log("C");
  }
}

type ReturnB = () => B;

const foo: ReturnB = () => new A(); // Property 'methodB' is missing in type 'A' but required in type 'B'.ts(2741)
const bar: ReturnB = () => new B(); // ok
const baz: ReturnB = () => new C(); // ok

関数のパラメータについて

次に関数のパラメータについて検証していくが、実はこれは、tsconfig.jsonstrictFunctionTypesの設定によって変化する。
まずは、strictFunctionTypestrueのときの挙動を見ていく。
ちなみに、strictFunctionTypesstrictに含まれているので、strictフラグを有効にすると自動的に有効になる。

TakeBBを受け取る関数だが、Aを受け取る関数も割り当てることができる。だが、Cを受け取る関数は割り当てられない。
つまり、「関数は、そのパラメータに関して反変である」といえる。

class A {
  methodA() {
    console.log("A");
  }
}

class B extends A {
  methodB() {
    console.log("B");
  }
}

class C extends B {
  methodC() {
    console.log("C");
  }
}

type TakeB = (arg: B) => void;

const foo: TakeB = (arg: A) => {}; // ok
const bar: TakeB = (arg: B) => {}; // ok
const baz: TakeB = (arg: C) => {}; // Property 'methodC' is missing in type 'B' but required in type 'C'.ts(2322)

次に、strictFunctionTypesfalseにして同じコードを実行してみる。
すると、どのパターンもエラーを出さなくなる。

const foo: TakeB = (arg: A) => {}; // ok
const bar: TakeB = (arg: B) => {}; // ok
const baz: TakeB = (arg: C) => {}; // ok

Bに対して、そのスーパータイプもサブタイプも割り当てることができるので、双変である。

つまり、以下のようになる。

strictFunctionTypes 関数パラメータの変性
true 共変
false 双変

strictFunctionTypesfalseにした場合、以下のコードを書いても TypeScript はエラーを出さない。
funcTakeBを受け取るが、双変なので、パラメータがCであるcallMethodCも受け入れてしまう。
そして、funcのなかでBを渡しているが、BmethodCを持っていないので、プログラムの実行時にエラーになってしまう。

class A {
  methodA() {
    console.log("A");
  }
}

class B extends A {
  methodB() {
    console.log("B");
  }
}

class C extends B {
  methodC() {
    console.log("C");
  }
}

type TakeB = (arg: B) => void;

const func = (arg: TakeB) => {
  const b = new B();
  arg(b); // TypeError: c.methodC is not a function 実行時エラー
};

const callMethodC = (c: C) => {
  c.methodC();
};

// (c: C) => void を受け入れてしまう
func(callMethodC);

strictFunctionTypestrueにしてパラメータを反変にすることで、このエラーを事前に防ぐことができる。
反変なので、Cをパラメータとして受け取る関数をTakeBに割り当てることはできず、TypeScript がエラーを検知してくれる。

class A {
  methodA() {
    console.log("A");
  }
}

class B extends A {
  methodB() {
    console.log("B");
  }
}

class C extends B {
  methodC() {
    console.log("C");
  }
}

type TakeB = (arg: B) => void;

const func = (arg: TakeB) => {
  const b = new B();
  arg(b);
};

const callMethodC = (c: C) => {
  c.methodC();
};

func(callMethodC); // Property 'methodC' is missing in type 'B' but required in type 'C'.ts(2345)

ちなみに、反変なのでAをパラメータとして受け取る関数をTakeBに割り当てることができるが、これは問題にならない。
callMethodABを渡しても、BAを継承しているためmethodAを実行できる。

class A {
  methodA() {
    console.log("A");
  }
}

class B extends A {
  methodB() {
    console.log("B");
  }
}

class C extends B {
  methodC() {
    console.log("C");
  }
}

type TakeB = (arg: B) => void;

const func = (arg: TakeB) => {
  const b = new B();
  arg(b);
};

const callMethodA = (a: A) => {
  a.methodA();
};

func(callMethodA); // A

参考資料