TypeScript の陥りやすい罠

TypeScript の陥りやすい罠

Oreilly の Effective Typescript を読んだ。説明が簡潔でわかりやすく、章の構成も読みたいところだけ読めば良いようになっており、すらすら読める。対象読者層はある程度 TypeScript を使っており、ひと通りの機能を触ったことがある人だと思う。全くの初心者はまず Handbookを一通り眺めてみることをおすすめする。本書籍はTypeScript本の一冊目として手に取るものではない。

2 年くらい TypeScript を使っているが意外と詳しく知らない動作やうろ覚えだった機能についても書かれており、参考になった。Conditional TypesやUtility Typesについては薄めだったのは残念。

本書を読んだ上で、Handbook で気になる部分を再度読み直したり、TypeScript リポジトリの Issue を読むことで浮かび上がってきた、 TypeScript を使う際に気をつけるべき点、他の静的型言語とは異なる点をいくつかまとめておく。(なので本書に載っていないものもある)

動作確認は TypeScript v4.1.5 で行った。

削除される型

TypeScript の型情報は JavaScript コードには一切残らない。

そのため、型に対してtypeofinstanceofなどの JavaScript 上の値を扱う演算子を使うことはできない。

Playground Link

interface Square {
  width: number;
}

interface Rectangle {
  height: number;
  width: number;
}

function getArea(shape: Square | Rectangle) {
  if (typeof shape === Square) {
    // 'Square' only refers to a type, but is being used as a value here.(2693)
  }
  if (shape instanceof Rectangle) {
    // 'Rectangle' only refers to a type, but is being used as a value here.(2693)
  }
}

上記コードのように Union 型のメンバを判別したい場合は、in演算子を使い、オブジェクトが固有のプロパティを持っているかどうかで判別するか:

Playground Link

interface Square {
  width: number;
}

interface Rectangle {
  height: number;
  width: number;
}

function getArea(shape: Square | Rectangle) {
  if ("height" in shape) {
    shape; // (parameter) shape: Rectangle
  } else {
    shape; // (parameter) shape: Square
  }
}

"タグ"付きの Union として Union のメンバに共通するプロパティをもたせることで判別する。下記の例では、kindプロパティを持たせている。

Playground Link

interface Square {
  kind: "square";
  width: number;
}

interface Rectangle {
  kind: "rectangle";
  height: number;
  width: number;
}

function getArea(shape: Square | Rectangle) {
  if (shape.kind === "square") {
    shape; // (parameter) shape: Square
  } else {
    shape; // (parameter) shape: Rectangle
  }
}

ちなみにclass構文は、同時にinterface型を作成するため、次のコードは正しい:

Playground Link

class Square {
  constructor(public width: number) {
    this.width = width;
  }
}

class Rectangle {
  constructor(public width: number, public height: number) {
    this.width = width;
    this.height = height;
  }
}

function getArea(shape: Square | Rectangle) {
  if (shape instanceof Square) {
    shape; // (parameter) shape: Square
  } else {
    shape; // (parameter) shape: Rectangle
  }
}

keyof Union は never 型

keyofは型にアクセス可能なプロパティのキーを返す。共通のプロパティを持たない型同士の Union であれば、その型にアクセス可能なプロパティキーは存在しないので、neverとなる。

Playground Link

GitHub Issue

interface Person {
  name: string;
}
interface Lifespan {
  birth: Date;
  death?: Date;
}
type UnionKey = keyof (Person | Lifespan); // never
type IntersectionKey = keyof (Person & Lifespan); // "name" | "birth" | "death"

これは以下のコードが型エラーになるのと同じ理由

Playground Link

interface Person {
  name: string;
}
interface Lifespan {
  birth: Date;
  death?: Date;
}

declare const personOrLifespan: Person | Lifespan;
if (personOrLifespan.name) {
  // Property 'name' does not exist on type 'Person | Lifespan'. Property 'name' does not exist on type 'Lifespan'.
}

personOrLifespanの型は、以下の型 ではない

type PersonOrLifespan = {
  name?: string;
  birth?: Date;
  death?: Date;
}

オブジェクトは単一の厳密な型を作成しない

Handbook

Playground Link

interface Person {
  name: string;
  age: number;
  address: string;
}

interface Animal {
  name: string;
  age: number;
}

function callAnimal(animal: Animal) {
  console.log(`${animal.name}`);
}

const person: Person = { name: "John", age: 22, address: "12345" };
callAnimal(person); // Animal型の引数にPerson型のオブジェクトを割り当てることができる

TypeScript のオブジェクトは単一の厳密な型を作成しない。構造的型システムである TypeScript において、オブジェクトの形状の一部分が型の形状と一致すれば、そのオブジェクトは型に割り当てることができると判断される。

上記コードでいえば、name: stringage: number;のプロパティを持つオブジェクトはAnimal型でもあるとみなされる。

"exccess property checking"はオブジェクトリテラルの作成時にしか発生しない

次のコードは、構造的型システムの観点からすれば正しいように思える。animalオブジェクトはAnimal型に必要なすべてのプロパティを持っているし、プロパティの値の型も間違っていない。しかし、TypeScript は型エラーを警告してくれる。

interface Animal {
  name: string;
  age: number;
}

const animal: Animal = {
  name: "John",
  age: 22,
  address: "12345", // Type '{ name: string; age: number; address: string; }' is not assignable to type 'Animal'. Object literal may only specify known properties, and 'address' does not exist in type 'Animal'.
};

これは"exccess property checking"と呼ばれるチェック機能によるもので、オブジェクトリテラルを作成したときのみに動作し、不要なプロパティの存在やプロパティ名をタイプミスしていないか検証してくれる。

以下のコードでは"exccess property checking"は起こらない。

Playground Link

interface Animal {
  name: string;
  age: number;
}

const animal = {
  name: "John",
  age: 22,
  address: "12345",
};

const obj: Animal = animal;

type エイリアスと interface の違い

  • interfaceUnion型を拡張できない。一度typeを経由する必要がある

Playground Link

interface Square {
  width: number;
}

interface Rectangle {
  height: number;
  width: number;
}

type TSquareOrRectangle = Square & Rectangle;

interface IShape extends TSquareOrRectangle {}
  • interfaceでは Named Tuple が表現できない

TypeScript のテストケースを探ったりコミッターに聞いてみたりしたができないっぽい

Handbook

Playground Link

type TLocation = [lat: number, long: number]; // [lat: number, long: number]

interface ILocation extends Array<number> {
  0: number;
  1: number;
  length: 2;
}

const location1: TLocation = [1, 2];
location1[0]; // (property) 0: number (lat)

const location2: ILocation = [1, 2];
location2[0]; // (property) ILocation[0]: number

interfaceは"declaration merging"が可能

interfaceの型宣言を結合する。例えば TypeScript のコンパイラオプションlibes2016を指定すると、"declaration merging"によって既存の型に新しい ECMA のメソッドが追加される。

混乱のもとになるので、巨大なライブラリの製作者でなければ、この機能はあまり使用しなくて良いと思われる。別の名前で個別に型を定義し、拡張することをおすすめする。

Playground Link

interface Rectangle {
  height: number;
}

interface Rectangle {
  width: number;
}

const rectangle: Rectangle = {
  height: 200,
  width: 100,
};

ECMAScript にない言語機能

Enum

Enum は結構癖が強い。通常のenumJavaScript オブジェクトに変換されるのに対して、const enumは TypeScript の型システム上でしか存在しない。

Playground Link

enum FlavorEnum {
  VANILLA = 1,
  CHOCOLATE = 2,
  STRAWBERRY = 99,
}

let flavorEnum = FlavorEnum.CHOCOLATE;
FlavorEnum[0]; // <- indexでアクセスできてしまう

const enum FlavorConstEnum {
  VANILLA = 1,
  CHOCOLATE = 2,
  STRAWBERRY = 99,
}

let flavorConstEnum = FlavorConstEnum.CHOCOLATE;
FlavorConstEnum[0]; // A const enum member can only be accessed using a string literal.(2476)

文字列を割り当てる(string enum)こともできるが、これは構造的型システムの TypeScript の中でも特殊なふるまいになる。構造的型システムからすれば下記のflavorは文字列リテラル型の"strawberry"を受け入れても良さそうだが、型エラーが発生する:

Playground Link

enum FlavorEnum {
  VANILLA = "vanilla",
  CHOCOLATE = "chocolate",
  STRAWBERRY = "strawberry",
}

let flavor = FlavorEnum.CHOCOLATE;
flavor = "strawberry"; // Type '"strawberry"' is not assignable to type 'FlavorEnum'.(2322)

下記画像のように Enum のメンバにコメントを付けたい、とかでなければ文字列リテラルの Union を使ったほうが良い:

image

Playground Link

type FlavorUnion = "vanilla" | "chocolate" | "strawberry";

let flavor: FlavorUnion = "vanilla";

TypeScript のソースコードを読んでみると、ビットによるフラグ管理に Enum が使われていることが分かる。おそらくはこのために Enum が導入されたのだろう。

ビット演算子を使うと複数のメンバを持つメンバがかなり書きやすい。

Playground Link

// ビット演算時、数値は32ビットの整数値に変換される
const enum Flavor {
  /** 1 (32ビットの整数値: 0000 0000 0000 0000 0000 0000 0000 0001) */
  VANILLA = 1 << 0,
  /** 2 (32ビットの整数値: 0000 0000 0000 0000 0000 0000 0000 0010) */
  CHOCOLATE = 1 << 1,
  /** 4 (32ビットの整数値: 0000 0000 0000 0000 0000 0000 0000 0100) */
  STRAWBERRY = 1 << 2,
  /** 7 (32ビットの整数値: 0000 0000 0000 0000 0000 0000 0000 0111) */
  All = VANILLA | CHOCOLATE | STRAWBERRY,
}

function order(flavor: Flavor) {
  if (flavor & Flavor.VANILLA) {
    throw new Error("VANILLA is out of stock!");
  }
}

let chocolate = Flavor.CHOCOLATE;
order(chocolate);

let all = Flavor.All;
order(all); // Error: VANILLA is out of stock!

let vanilla = Flavor.VANILLA;
order(vanilla); // Error: VANILLA is out of stock!

コンパイルされた JS コードにも全く無駄がない。ソースコードの圧縮にも一役買っているようだ:

"use strict";
function order(flavor) {
    if (flavor & 1 /* VANILLA */) {
        throw new Error("VANILLA is out of stock!");
    }
}
let chocolate = 2 /* CHOCOLATE */;
order(chocolate);
let all = 7 /* All */;
order(all); // Error: VANILLA is out of stock!
let vanilla = 1 /* VANILLA */;
order(vanilla); // Error: VANILLA is out of stock!

複雑なEnumを持たない通常のアプリ規模では必要ないテクニックだが、置き換えができないケースもあることは知っておくべきだろう。

Decorator

experimentalDecoratorsオプションを指定すれば TypeScript でも Decorator は使用できるが、避けるべき。TC39でも仕様は定まっていない。加えて TypeScript で使用できる Decorator の実装は 2014 年の提案のものであり、将来的に実装に破壊的変更が入る可能性が高い。

Angular や NestJS では当たり前のように Decorator を使っているが、対応に追われそうだ。

type-widening

TypeScript の型の推論はとても賢く、いちいち型注釈を付けなくても型が付くという安心を得られる。しかしそれゆえに、混乱を引き起こすケースがある。

次のケースでは、alice変数は文字列リテラル型である"Alice"ではなく、文字列型として推論されるため型エラーが発生する。

Playground Link

type Name = "Alice" | "Bob";

declare function setName(name: Name): void;

setName("Alice");

let alice = "Alice"; // let alice: string
setName(alice); // Argument of type 'string' is not assignable to parameter of type 'Name'.(2345)

これを解消するには、型注釈を使う、あるいはconstにより文字列リテラル型として変数を宣言する:

Playground Link

type Name = "Alice" | "Bob";

declare function setName(name: Name): void;

let alice: Name = "Alice";
setName(alice);

const bob = "Bob";
setName(bob);

type-narrowing

Handbook

control flow analysisのおかげで、型チェッカーが変数の処理を追ってくれるので、型制限を維持したまま、変数に「その時点でもっともあり得る型」が割り当てられる。

Playground Link

function foo(x: string | number | boolean): number | boolean {
  if (typeof x === "string") {
    x; // (parameter) x: string
    x = 1;
    x; // (parameter) x: number
    // x = {}; // ← xはもともと string | number | boolean のUnion型なので、オブジェクトは代入できない Type '{}' is not assignable to type 'string | number | boolean'.
  }
  return x; // (parameter) x: number | boolean
}

ただしunknownに対しては、うまく動作しない。否定型(Negated Type)が導入されればやりようはありそうな気がする。

GitHub Issue

Playground Link

function foo(x: unknown): unknown {
  if (x == null) {
    x; // (parameter) x: unknown (実際は null | undefined)
    x = 1;
    x; // (parameter) x: unknown (実際は数値)
  } else if (isObject(x)) {
    x; // (parameter) x: object | null (実際はオブジェクト)
    x = 2;
    x; // (parameter) x: unknown
  }
  return x; // (parameter) x: unknown
}

function isObject(x: unknown): x is object {
  return typeof x === "object";
}

JavaScript のスーパーセットであるということ

動作するあらゆる JavaScript は、(型エラーが発生するかどうかに関わらず)TypeScript コンパイラを通しても JavaScript に変換され、変わらず動作する。

TypeScript の原理原則として、JavaScript のランタイム動作を決して変更しないというものがある。また一部型システムにおいてもその動作を尊重している。

例えば、以下のコードは TypeScript として正しい。JavaScript では文字列と数値を加算演算子でつなぐと、数値は文字列に型強制される。

TypeScript はこのふるまいを尊重するため型チェッカーはエラーを発生させない。

const x = "1" + 1; // "11"
const y = 1 + "1"; // "11"

しかし、TypeScript はどこかで線引をする。以下のコードは JavaScript の構文的に正しく、ランタイムエラーを発生させないが、TypeScript の型チェッカーは警告を表示する。

const x = 1 + []; // Operator '+' cannot be applied to types 'number' and 'never[]'.(2365)
// JavaScriptでは、結果は 1
const y = 1 + null; // Object is possibly 'null'.(2531)
// JavaScriptでは、結果は 1

線引するポイントは、こうしたコードをプログラマが意図的に書いているかよりもバグの可能性が高いと思われるかどうかだろう。具体的には、配列やnullを数値に加算するコードはプログラマが意図しないもの(バグ)であると TypeScript のコミッターが判断しているのだと思う。