TypeScript の陥りやすい罠
TypeScript の陥りやすい罠
Oreilly の Effective Typescript を読んだ。説明が簡潔でわかりやすく、章の構成も読みたいところだけ読めば良いようになっており、すらすら読める。対象読者層はある程度 TypeScript を使っており、ひと通りの機能を触ったことがある人だと思う。全くの初心者はまず Handbookを一通り眺めてみることをおすすめする。本書籍はTypeScript本の一冊目として手に取るものではない。
2 年くらい TypeScript を使っているが意外と詳しく知らない動作やうろ覚えだった機能についても書かれており、参考になった。Conditional TypesやUtility Typesについては薄めだったのは残念。
本書を読んだ上で、Handbook で気になる部分を再度読み直したり、TypeScript リポジトリの Issue を読むことで浮かび上がってきた、 TypeScript を使う際に気をつけるべき点、他の静的型言語とは異なる点をいくつかまとめておく。(なので本書に載っていないものもある)
動作確認は TypeScript v4.1.5 で行った。
削除される型
TypeScript の型情報は JavaScript コードには一切残らない。
そのため、型に対してtypeof
やinstanceof
などの JavaScript 上の値を扱う演算子を使うことはできない。
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
演算子を使い、オブジェクトが固有のプロパティを持っているかどうかで判別するか:
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
プロパティを持たせている。
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
型を作成するため、次のコードは正しい:
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
となる。
interface Person { name: string; } interface Lifespan { birth: Date; death?: Date; } type UnionKey = keyof (Person | Lifespan); // never type IntersectionKey = keyof (Person & Lifespan); // "name" | "birth" | "death"
これは以下のコードが型エラーになるのと同じ理由
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; }
オブジェクトは単一の厳密な型を作成しない
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: string
とage: 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"は起こらない。
interface Animal { name: string; age: number; } const animal = { name: "John", age: 22, address: "12345", }; const obj: Animal = animal;
type エイリアスと interface の違い
interface
はUnion
型を拡張できない。一度type
を経由する必要がある
interface Square { width: number; } interface Rectangle { height: number; width: number; } type TSquareOrRectangle = Square & Rectangle; interface IShape extends TSquareOrRectangle {}
interface
では Named Tuple が表現できない
TypeScript のテストケースを探ったりコミッターに聞いてみたりしたができないっぽい
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 のコンパイラオプションlib
にes2016
を指定すると、"declaration merging"によって既存の型に新しい ECMA のメソッドが追加される。
混乱のもとになるので、巨大なライブラリの製作者でなければ、この機能はあまり使用しなくて良いと思われる。別の名前で個別に型を定義し、拡張することをおすすめする。
interface Rectangle { height: number; } interface Rectangle { width: number; } const rectangle: Rectangle = { height: 200, width: 100, };
ECMAScript にない言語機能
Enum
Enum は結構癖が強い。通常のenum
は JavaScript オブジェクトに変換されるのに対して、const enum
は TypeScript の型システム上でしか存在しない。
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"
を受け入れても良さそうだが、型エラーが発生する:
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 を使ったほうが良い:
type FlavorUnion = "vanilla" | "chocolate" | "strawberry"; let flavor: FlavorUnion = "vanilla";
TypeScript のソースコードを読んでみると、ビットによるフラグ管理に Enum が使われていることが分かる。おそらくはこのために Enum が導入されたのだろう。
ビット演算子を使うと複数のメンバを持つメンバがかなり書きやすい。
// ビット演算時、数値は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"
ではなく、文字列型として推論されるため型エラーが発生する。
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
により文字列リテラル型として変数を宣言する:
type Name = "Alice" | "Bob"; declare function setName(name: Name): void; let alice: Name = "Alice"; setName(alice); const bob = "Bob"; setName(bob);
type-narrowing
control flow analysis
のおかげで、型チェッカーが変数の処理を追ってくれるので、型制限を維持したまま、変数に「その時点でもっともあり得る型」が割り当てられる。
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
)が導入されればやりようはありそうな気がする。
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 のコミッターが判断しているのだと思う。