🛠️

TypeScript 3.7 ~ 4.2 までの主な変更をまとめて復習する

2021/05/24に公開

自ブログからの引用です。

<div style={{textAlign: 'center'}}>
<img style={{width: 220}} src="/images/typescript/logo.png"/>
</div>

概要

最近久しぶりにTypeScriptのリリースを追って見たのですが、意外とTypeScriptの各バージョン・機能毎の詳細な記事は見かけますが、
全体的に振り返れる様な情報がなかったので、このまとめを作成しました。

本記事では2019年後半の v3.7 から 執筆時点で最新の v4.2 まで、期間にして最近一年ちょっとの主なリリースを
既存の記事を引用しながらまとめて行きたいと思います。読んでみると分かりますが、結構分量があります。

時系列としては以下の通りです。

バージョン リリース日
4.2 2021年02月26日
4.1 2020年11月20日
4.0 2020年08月21日
3.9 2020年04月29日
3.8 2020年01月17日
3.7 2019年10月02日

※ 4.0はメジャーバージョンアップではありません。

なお、一次情報としてはこちらのアナウンスメントを参考にしております。

Template Literal Types v4.1

いきなり最近ですが、かなりインパクトの大きな更新でした。
これはテンプレート文字列の定義と同じ方法で、プレースホルダ付きの文字列型が定義できる様になる変更です。

// 今まで
type VerticalAlign = 'top' | 'bottom';
// 新たにこんな型が定義できる様になりました。
type Decorated = `---${VerticalAlign}---`;
// = '---top---' | '---bottom---'

この様にプレイスホルダ部分にunion型が入る場合は全ての組み合わせで展開されます。

紹介されている用例で分かりやすかったのは、上下左右の配置を指定するよくある型の生成です。

type VerticalAlign = 'top' | 'middle' | 'bottom';
type HorizontalAlignment = 'left' | 'center' | 'right';
type Alignment = `${VerticalAlign}-${HorizontalAlignment}`;
// =>  | "top-left"       | "top-center"        | "top-right"
//        | "middle-left"  | "middle-center"   | "middle-right"
//        | "bottom-left" | "bottom-center" | "bottom-right"

さらに、型計算の為の新たな(特殊な)4つの型関数が追加されています。

type HelloWorld = 'HelloWorld';
type HELLOWORLD = `${Uppercase<HelloWorld>}`;
type helloworld = `${Lowercase<HelloWorld>}`;
// type HelloWorld = `${Capitalize<HelloWorld>}`;
type helloWorld = `${Uncapitalize<HelloWorld>}`;

詳細は下記が参考になります。

タプル

ちょっと長いです。

タプルは非常に便利な概念?だと思います。TypeScriptの場合はArrayを使用して(無理矢理)タプルっぽい機能を実装しています。
タプルとは下記の様な、中身の型が指定された配列型です。

3.9以前では以下の様な機能でした。

type TupleA = [string, number?];
const a1: TupleA = ['arg1']; // OK
const a2: TupleA = ['arg1', 1]; // OK
const a3: TupleA = ['arg1', 1, 2]; // Error
const a4: TupleA = ['arg1', 'arg2']; // Error

// 「最後」の要素は特別な「レストパラメータ」としても定義できる
type TupleB = [string, number, ...boolean[]];
const b1: TupleB = ['arg1', 1, false]; // OK
const b2: TupleB = ['arg1', 1, false, false]; // OK
const b3: TupleB = ['arg1', 1]; // OK
const b4: TupleB = ['arg1', 1, 2]; // Error
const b5: TupleB = ['arg1', 1, false, 2]; // Error

こんな感じで、配列の要素の型を決めておけるので、例えば複数の値を返す関数の戻り値の型などに便利な機能です。

function numAndString (): [number, string] {
  return [100, 'test'];
}

一方、いくつかの強い制約がありました。

  1. 可変長のレストパラメータは最後に1回だけしか使えない
  2. レストパラメータは配列の形式でしか使えない( ...string[]の様に )

そこで、v4.0Variadic Tuple Types がリリースされ、2が改善されました。

参考: TypeScript 4.0で導入されるVariadic Tuple Typesをさっそく使いこなす

これは2つの変更が含まれています。

1つ目は ...T の様にタプル型に他のタプル型をスプレッド演算子で展開できる様になりました。
(より正確にはタプル型のスプレッドがジェネリックとして定義され、実際の値に置き換えることでタプルを組み合わせ可能になりました。)

type TupleC = [string, string];
type TupleD = [number, number];
type TupleE = [...TupleC, ...TupleD];

逆に型をスプレッド演算子にキャプチャすることもできます。

function tail<T extends any[]>(arr: readonly [any, ...T]) {
  const [_ignore, ...rest] = arr;
  return rest;
}
const tuple1 = [1, 2, 3, 4] as const;
const r1 = tail(tuple1); // => r1: [2, 3, 4]
const tuple2 = ['hello', 'world'] as const;
const r2 = tail([...tuple1, ...tuple2]); // => r2: [2, 3, 4, 'hello', 'world']

2つ目の変更点は、このタプルのスプレッドはどこにでも配置できます。

type Strings = [string, string];
type Numbers = [number, number];
type StrStrNumNumBoolArr = [...Strings, ...Numbers, boolean[]];

// ただし、配列形式のレストパラメータは最後に一度だけしか使用できません
type Tuple = [...Strings, ...string[], ...Numbers]; // Error

さらに v4.2 では Leading/Middle Rest Elements in Tuple Types がリリースされ、
タプルの最初や中間でも配列のレストパラメータを使用することができる様になりました。

type Tuple = [...string[], number];

// ただし、レストパラメータがつかえるのは1回だけです。
type A = [...string[], ...boolean[], number]; // Error
// また、レストパラメータの後にはオプショナルパラメータはこれません。
type B = [...string[], boolean?, number]; // Error

少し話はそれますが、v3.9 のリリースでは Labeled Tuple Elements がリリースされ、タプルの要素を命名できる様になりました。

この様に、タプル型は短期間のうちに可能な限り柔軟になってきました。

const example = (...args: [first: number, second: string]) => { /* ... */};

これは単純にドキュメンテーションの為の改善です。

ECMA Scriptの機能

ECMA Script 標準で実装されている(実装されそう)な機能がTSに実装されたものです。

Optional Chaining v3.7

proposal-optional-chainingで提案された、ES2020 の機能です。

以下の2文は同じになります。

a && a.b && a.b();
// ↓
a?.b?.();

Nullish Coalescing v3.7

proposal-nullish-coalescingで提案された、ES2020 の機能です。

以下の2文は同じになります。

const b = a !== null && a !== void 0 ? a : 'null or undefined';
// ↓
const b = a ?? 'null or undefined';

Private Fields v3.8

[proposal-class-fields](https://github.com/tc39/proposal-class-fields、現在 Stage3 の ES2022 に入る予定の機能です。
(Chromeはv74から、Node.jsはv12から実装されています。)

クラスにプライベートなフィールドを定義できます。

class A {
  // '#'で始まるフィールドがプライベートになる
  #privateField;

  constructor() { this.#privateField = 'hoge' }
}

TypeScriptの private 修飾子との違いが気になりますが、private はJS変換後に消去されるのに対して、
'#'で始まフィールドはhard privacyと呼び、完全に定義されたクラス内のスコープに存在するので挙動が異なります。

// 以下は違いの例
class A {
  #hard: number = 10;
  getHardA = () => this.#hard;
}

class B extends A {
  // privateと違いoverrideできるが
  #hard: number = 20;
  getHardB = () => this.#hard;
}
const b = new B();
b.getHardA(); // 10 <= 別のスコープの変数として保存されている
b.getHardB(); // 20

Short-Circuiting Assignment Operators

proposal-logical-assignmentで提案された、 ES2020 の機能です。

下記の3対の文は同じです。

// 1. a が falsy なら代入する
a ||= b;
a || (a = b);

// 2. a が Truthy なら代入する
a &&= b;
a && (a = b);

// 3. a が null か undefined なら代入する
a ??= b;
a ?? (a = b);
// a !== null && a !== undefined ? a : (a = b);

Key Remapping in Mapped Types v4.1

Mapped Type を作成する時に、キーを変換することができる様になりました。

type Getters<T> = {
  // ここの as に注目
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
type A = Getters<{name: string, age: number}>;
// => {
//   getName: () => string,
//   getAge: () => number,
// }

Assertion Functions v3.7

Type Guardをご存知でしょうか?

Assertion FunctionsはType Guardと同様に、コンパイラにユーザ側が責任を持って型安全を保証する機能です。

例えば、以下の場合itemは number | undefined に推論されます。

const myArray = [1, 2, 3];
for (const index in myArray) {
  const item = myArray[index];
  // const item: number | undefined;
}

その為、itemをnumberとして操作したい場合は undefined でない事ををチェックしないといけません。

const item = myArray[index];
if (typeof item !== 'number') return 0;
return item;
// const item: number;

(asを使ってダウンキャストすることも可能ですが、なんのチェックもせずに型を変えてしまうのは基本的に危険です。)

ここで、numberかどうかの判定は良く使いそうなので、typeof item !== 'number' を関数に切り出してみましょう。

function isNumber (value: any) {
  return typeof value === 'number';
}
const item = myArray[index];
if (!isNumber(item)) return 0;
return item;
// const item: number | undefined;

この様に、切り出した場合は型の条件が絞りこまれません。この現象に開発者側が自責で対応する方法が type guardです。

// value is numberの部分でvalueの型を開発者が保証する
function isNumber (value: any): value is number {
  return typeof value === 'number';
}
const item = myArray[index];
if (!isNumber(item)) return 0;
return item;
// const item: number;

これと同じ操作をアサーションで使える様にしたのが、本題のassertion functionsです。
type guard はif文で使用しましたが、assertion functionsは条件を満たさない場合に throw する事を、
開発者側が保証することができます。

// value is numberの部分でvalueの型を開発者が保証する
function isNumber (value: any): asserts value is number {
  if (typeof value !== 'number') {
    throw new TypeError('test');
  }
}
const item = myArray[index];
isNumber(item);
return item;
// const item: number;

今回は for-in でループを回しており、itemがundefinedにならないのは、
開発者から見れば納得ができる部分なので、assertion functionsを使用しても問題ないと思われます。

下記の参考の通り、どちらもTSの型チェックをマヒさせる危険性があるので、使い所は慎重に考える必要がありそうです。

参考

Smarter Type Alias v4.2

結構便利な変更です。以下の doStuff 関数の戻り値の型を考えます。

type BasicPrimitive = number | string | boolean;

export function doStuff(value: BasicPrimitive) {
  if (Math.random() < 0.5) {
    return undefined;
  }
  return value;
}

v4.1 以前は number | string | boolean | undefined に推論されてましたが、

v4.2 では BasicPrimitive が保持されて BasicPrimitive | undefined になりました。

コメント系

// @ts-expect-error Comments v3.9

まずは下記のコードをみてください。

// ライブラリを開発している想定想定

import assert from 'assert';
function doStuff(abc: string, xyz: string) {
  // 下記の様なアサーションを使用して、JavaScriptユーザに警告を与えたい!
  assert(typeof abc === "string");
  assert(typeof xyz === "string");
}

この関数のテストを書く場合、あえて型を間違えた異常系も検査したいと考えると思います。

expect(() => {
  // 123のところで、TSがエラーする
  doStuff(123, 456);
}).toThrow();

これではまずTSがうまく動かなくなってしまいます。そこで、@ts-expect-error コメントをつけることで、
TSのエラーを抑制することができます。

expect(() => {
  // @ts-expect-error
  doStuff(123, 456); // OK
}).toThrow();

@ts-ignore との違いはややこしいですが、@ts-expect-errorの場合はTSの静的なエラーがでない場合はエラーします。

以下の通りです。

expect(() => {
  // @ts-expect-error
  doStuff('123', 456); // NG
  // @ts-ignore
  doStuff('123', 456); // OK (になってしまう)
}).toThrow();

これにより、エラーが発生することが見込まれる場合は @ts-expect-erorr を使用した方が、より適切な情報を持ったコードになります。

@ts-nocheck v3.7

// @ts-nocheck

これを記述すると、ファイル全体の型検査をしなくなります。
JSとTSに移行する途中などで、利用できます。

abstract Construct Signatures v4.2

// 今まで、クラスコンストラクタの型は以下の様に宣言できましたが、
type ClassConstructor = new () => {};
// 4.2からは abstract 修飾子が加わり、abstractクラスのコンストラクタが区別される様になりました。
type AbstractClassConstructor = abstract new () => {};

詳細は以下をみてください。用例を考えるとちょっとややこしいです。

参考

Recursive Conditional Types v4.1

型の定義を再帰的に行った時に、それが解析される様になりました。
下記はネストされた配列をフラット化する deepFlatten 関数へ型をつける例です。

// ネストされた配列の要素の型を再帰的に返す
type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;

function deepFlatten<T extends unknown[]>(x: T): ElementType<T>[] {
  throw '実装する';
}

// 以下の3つは全て戻り値が number[] 型に推論されます
deepFlatten([1, 2, 3]);
deepFlatten([[1], [2, 3]]);
deepFlatten([[1], [[2]], [[[3]]]]);

v4.0 までは全て、ElementType<number> 型に推論されていた為、型の定義ができてもうまく静的解析が走らない状態でした。

Class Property Inference from Constructors v4.0

コンストラクタで初期化した時に代入される型にフィールドの型が推論されます。

class Square {
  area;       // numberに推論される
  sideLength; // numberに推論される

  constructor(sideLength: number) {
    this.sideLength = sideLength;
    this.area = sideLength ** 2;
  }
}

以前は areasideLengthany に推論されていました。

constructorの中で初期化されない可能性があるプロパティはちゃんと undefined とのユニオンで推論されます。

class Person {
  name; // string | undefined に推論される

  constructor(name: string) {
    if (Math.random()) {
      this.name = name;
    }
  }
}

インデックスシグネチャに関する改修

Stricter Assignability Checks to Unions with Index Signatures v3.8

const obj1: { [x: string]: number } | { a: number } = { a: 5, c: 'abc' }; // error
// cの部分がエラーする様になった。

以前は c は過剰なプロパティとみなされ型のチェックがされなかったが、
v3.8からはインデックスシグネチャに含まれると判断し、numberで縛ってくれる。

noUncheckedIndexedAccessフラグの追加 v4.1

interface Options {
  hoge: string;
  huga: number;

  // インデックスシグネチャ
  [propName: string]: string | number;
}

function checkOptions(opts: Options) {
  opts.hoge; // string
  opts.fuga; // number

  opts.hogefuga.toString();
  // noUncheckedIndexedAccessが false(無指定) の時,
  // opts['c'] が string | number に推論されてエラーしない
  // noUncheckedIndexedAccessが true の時,
  // opts['c'] が string | number | undefined に推論され、エラーする
}

noPropertyAccessFromIndexSignatureフラグの追加 v4.2

trueにした場合はインデックスシグネチャに当たるプロパティにアクセスする時に、['name']の構文を使用しなくてはならない。

function checkOptions(opts: Options) {
  const hoge = opts.hoge; // OK
  const fuga = opts.fuga; // OK
  const hogefuga = opts.hogefuga; // noPropertyAccessFromIndexSignatureが true の時はエラーする
  // TS4111: Property 'c' comes from an index signature, so it must be accessed with ['c'].

  const d = opts['hogefuga']; // OK
}

これにより、インデックスシグネチャではないプロパティをタイポした時に警告が出る。

function checkOptions(opts: Options) {
  const a = opts.hogo; // Error
}

Relaxed Rules Between Optional Properties and String Index Signatures v4.2

type WesAndersonWatchCount = {
  "Fantastic Mr. Fox"?: number;
  "The Royal Tenenbaums"?: number;
};

declare const wesAndersonWatchCount: WesAndersonWatchCount;
// 分割代入の時に、以前であればエラーが発生していたが、
// 定義されてないプロパティは消去される事をTSが理解してくれる。
const movieWatchCount: { [key: string]: number } = wesAndersonWatchCount;

useDefineForClassFieldsの導入 v3.7

TSとECMAScriptでクラスフィールドの生成方法にズレが生じたが、ブレーキングチェンジになる為、フラグとして
ECMAScriptに寄せる機能をリリースしました。 useDefineForClassFields をONにする事が強く推奨されています。

詳細:

その他細かい変更点

unknown on catch Clause Bindings v4.0

キャッチでバインドする変数は、以前は any 型でしたが、unknown 型として受け取る事ができる様になりました。

try {}
catch (x: unknown) {
  // 明示的に unknown がつけられる
  // しっかりプロパティチェックをしないとエラーする
  console.log(x.message); // error

  if (x instanceof Error) {
    console.log(x.message); // OK
  }
}

Stricter Checks For The in Operator v4.2

in 演算子に primitive 型が使えなくなりました。

const _1 = "foo" in { a: 42 }; // OK
const _2 = "foo" in [42]; // OK
const _3 = "foo" in string; // error

Improvements in Inference and Promise.all v3.9

Promise.allの推論が改善されました。

interface Lion { eatMeets(): void }
interface Elephant {eatVegetables(): void }
async function visitZoo(lionExhibit: Promise<Lion>, elephantExhibit: Promise<Elephant | undefined>) {
  let [lion, elephant] = await Promise.all([lionExhibit, elephantExhibit]);
  lion.eatMeets();
  //以前は, Object is possibly 'undefined'. でエラーした
}

export * as ns Syntax v3.8

import * as SomeModule1 from "./some-module";
export { SomeModule1 };
// これが以下の様にかける様になった
export * as SomeModule2 from './some-module';

Type-Only Imports and Exports v3.8

型だけをimportしたい時に、明示的に型だけを使用する事を宣言できる。

import type { SomeThing } from './some-module';
// これはコンパイル時に確実に消去されるので、importに寄る副作用が起きない

参考文献

TSブログのアナウンスを地道に読み返したのですが、各回のリリースに関しては分かりやすい記事がすでにありますので、
分かりづらい部分はこちらも参考にさせていただきました。

まとめ

細かなものを含めると意外と追うのが大変でした。しかし、タプル、テンプレートリテラル、インデックスシグネチャなど
普段TSを使っていて「あれ、っちょっとゆるくない?」と思った部分がどんどん改修されてきている印象で、
TSは言語として確実に成長しているのだなぁと改めて感じました。

(もしこの記事を最後まで読んだ方がましたら、その根気に拍手を送りたいと思います。ありがとうございます。)

GitHubで編集を提案

Discussion