Closed13

TypeScript 4.4 変更点 まとめ

おーみーおーみー

TypeScript 4.4

TypeScript 4.4.0 (Beta) が 2021/06/29 に公開された.4.4.1 (RC) は 08/06,4.4.2 (Release) は 08/24 に公開される予定.4.4.1 (RC) は08/13に公開された.

おーみーおーみー

私の個人的なまとめにとどまらないほうが嬉しいので,コメントは歓迎しています.

なぜスクラップにしたのか?

4.3のときは普通の記事として投稿したが,言ってしまえばリリースノートを読んでコピペしてるだけの記事なので,「記事の質が低くて英語ドキュメント読んだほうが早い」という意見の攻撃範囲に入っており,勝手にダメージを受けてしまう.これを避けるために,スクラップならただの翻訳記事でもいいでしょ (むしろそのための機能じゃないか) という言い訳で武装してスクラップとして投稿することにした.

TS 4.3の記事はBetaで書いたのをRelease用に更新するか,それともRelease用の記事を新しく公開するかで悩んだ挙句何もしておらず,Betaの古い情報をぶん投げたままみたいな状態になってしまっている.コメントの集合であるスクラップは,新バージョンについてBetaからReleaseに向かって追い続けるという趣旨にちょうどマッチしているようにも思っている.

おーみーおーみー

Control Flow Analysis of Aliased Conditions

TypeScriptには control flow analysis もしくは type guard という機能があり,if などの制御構文で型チェックを書くと自動で型が絞り込まれる.

function foo(v: unknown) {
  const isString = typeof v === "string";
  if (isString) {
    // ここではvはstring
    console.log(v.toUpperCase());
  }
}

TS 4.4 以前では,型チェックを行っている箇所を変数に代入すると絞り込みが働かなくなっていたが,4.4ではちゃんと絞り込まれるようになる.チェックの結果を複数の箇所で使いまわすのがJavaScriptでの自然な形で書ける.

function foo(v: unknown) {
  const isString = typeof v === "string";
  if (isString) {
    // 4.3: vはunknown
    // 4.4: vはstring
    console.log(v.toUpperCase()); // 4.3 だとここでエラー
  }
}

ただし,代入先は const な変数でなければならない.

function foo(v: unknown) {
  // let はだめ
  let isString = typeof v === "string";
  if (isString) {
    // 4.3, 4.4: vはunknown
    console.log(v.toUpperCase()); // 型エラー
  }
}

readonly なプロパティでもよいようにも読めるのだが働かない.

function foo3(v: unknown) {
  const obj = {
    isString: typeof v === "string",
  } as const;

  if (obj.isString) {
    // vはunknown
    console.log(v.toUpperCase());
  }
}

typeof 演算子以外に,&& などでのチェックにも適用される.

function doSomeChecks(
  inputA: string | undefined,
  inputB: string | undefined,
  shouldDoExtraWork: boolean
) {
  // 記事ではletになってるがこれだと動かない
  const mustDoWork = inputA && inputB && shouldDoExtraWork;
  if (mustDoWork) {
    // Can access 'string' properties on both 'inputA' and 'inputB'!
    const upperA = inputA.toUpperCase();
    const upperB = inputB.toUpperCase();
    // ...
  }
}

いったん代入した条件をさらに論理演算で組み合わせて代入してもかまわない (一定以上の深さになると推論できないとされている).

function f2(x: "foo" | "bar" | "baz") {
  const isBar = x === "bar";
  const isBaz = x === "baz";
  const isBarOrBaz = isBar || isBaz;
  if (isBarOrBaz) {
    x; // "bar" | "baz"
  } else {
    x; // "foo"
  }
}

もちろん,いわゆるtagged unionパターンでも動く.

tagged union パターン
function map<T, R>(
  f: (t: T) => R,
  o: { type: "Some"; value: T } | { type: "None" }
) {
  const isSome = o.type === "Some";
  if (isSome) {
    return f(o.value);
  } else {
    return o;
  }
}

tagged unionのタグ部分を分割代入してもきちんと推論される.

function map<T, R>(
  f: (t: T) => R,
  o: { type: "Some"; value: T } | { type: "None" }
) {
  const { type } = o;
  if (type === "Some") {
    return f(o.value);
  } else {
    return o;
  }
}
おーみーおーみー

Symbol and Template String Pattern Index Signatures

最初のpull request,リテラル型なども許容
https://github.com/microsoft/TypeScript/pull/42514
実際にマージされたpull request,リテラル型などを不許可
https://github.com/microsoft/TypeScript/pull/44512

Generalized Index Signatures という名前で TS 4.2 とかのIteration Planにも載ってたやつ.

4.3まで,インデックスシグネチャのキーの型には stringnumberstring | number しか置くことができなかったが,これを緩和し,symbol やテンプレート文字列型を許容するようになる.

テンプレート文字列型
interface Data {
  [opt: `data-${string}`]: unknown;
}

const data: Data = {
  // 「data-」から始まるプロパティのみを受け付ける
  "data-foo": 3,
  "data-bar": 42,
};

const invalidData: Data = {
  // 「data-」で始まっていないのでエラー
  foo: 3,
  bar: 42,
};
シンボル
interface SymbolDict {
  [sym: symbol]: unknown;
}

const alpha = Symbol();
const bravo = Symbol();

const sdict: SymbolDict = { [alpha]: 41, [bravo]: 42 };

なお,Generalized Index Signaturesと呼ばれていたものと異なり,リテラル型や型引数は依然として置くことができない.リテラル型はわざわざインデックスシグネチャを使わずとも外延的に書くことができるからだろう.

interface Literal {
  // An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead. ts(1337)
  [opt: "foo" | "bar"]: number;
}

// Quick Fix でMapped typesを使った形に直される.
type Literal = {
  [opt in "foo" | "bar"]: number;
};

// Mapped typesはこのような型になる
type NervousLiteral ={
  foo: number;
  bar: number;
};
おーみーおーみー

Defaulting to the unknown type in Catch Variables

https://github.com/microsoft/TypeScript/issues/41016

コンパイルオプション --useUnknownInCatchVariables を追加する.このオプションを有効化すると,try-catch文の catch (e) で導入した変数 e がデフォルトで unknown 型になる.

// throw しそうな関数
declare function doSomethingDangerous(): void;
try {
  doSomethingDangerous();
} catch (error) {
  // デフォルト: errorはany型
  // --useUnknownInCatchVariables: errorはunknown型
  console.error(error.message); // オプションがtrueのときエラー
}

TS 4.0 で catch(error: unknown) という明示的な指定が可能になったのに続き,これをデフォルトの挙動にするオプションになっている.--useUnknownInCatchVariables 下では,逆に catch(error: any) とすることで明示的に any を使うことができる.

このオプションは strict に含まれるため,strict:true なコードベースでは catch (error) して得たerrorをanyとして扱っているコードが動かなくなる.

おーみーおーみー

厳密なオプショナルプロパティ

https://github.com/microsoft/TypeScript/issues/13195
https://github.com/microsoft/TypeScript/issues/44421

JavaScriptでは存在しないプロパティにアクセスするとundefinedが返る.一方で存在するプロパティにundefinedが入っている場合もありうる.

const x = {a: 3};
const y = {a: 3, b: undefined};
x.b; // xにプロパティbは存在しないのでundefined
y.b; // yのプロパティbの値はundefined

多くのコードでこれらのパターンは同一視されてきたので,TypeScriptはオプショナルなプロパティはundefinedとのユニオン型になるようにしてきた.

interface Person {
  name: string;
  age?: number; // age?: number | undefined になる
}

// Personとまったく同じ型
interface SameAsPerson {
  name: string;
  age?: number | undefined;
}

const p: Person = {
  name: "Makima",
  age: undefined, // undefinedを代入できる
};

TS 4.4 で導入される新しいコンパイルオプション --exactOptionalPropertyTypes を有効化すると,オプショナルなプロパティに勝手に | undefined が付かないようになる.

--exactOptionalPropertyTypes
interface Person {
  name: string;
  age?: number; // age?: number のまま
}

const p: Person = {
  name: "Makima",
  // Type 'undefined' is not assignable to type 'number'. ts(2322)
  age: undefined, // undefinedを代入できない
};

p.age; // プロパティアクセスは今まで通り number | undefined になる

これは「undefinedの代入可否」と「プロパティの省略可否」が直交するようになったとも表現できる.

|undefinedを| required | optional |
|---|---|---|---|
|代入できない|prop: T| prop?: T |
|代入できる| prop: T | undefined | prop?: T | undefined |

存在しないプロパティと存在するプロパティの区別がつくようになると,in などの動きを正確に表せるようになってうれしい.

'age' in p はプロパティ 'age'p に存在するときに true を返す.今までは「p.ageは存在するが値がundefined」という可能性があったのでオプショナルなプロパティの絞り込みに使えなかったが,これで使えるようになった.p.age !== undefined でもあまり困らないが.

const p: Person = {
  name: "Makima",
};

if ("age" in p) {
  // デフォルトではnumber | undefined
  // --exactOptionalPropertyTypes ではnumber
  p.age;
}

オブジェクトのスプレッド構文も存在しないプロパティとundefinedが入ったプロパティを区別する.

const x = {b: 23} // aが存在しない
const y = {a: undefined, b: 23}; // aが存在して値がundefined

const xx = {a:42, ...b};
const yy = {a:42, ...c};

console.log(xx.a); // 42
console.log(yy.a); // undefined

そのほか Object.assignObject.keys,for-inループなどでプロパティの存在有無が厳密に区別される.

このオプションは --strict には含まれず,別途有効化する必要がある.また当然ながら --strictNullChecks が無効だとはたらかない.

より実践的な立場に立った解説:
https://susisu.hatenablog.com/entry/2021/07/13/000239

おーみーおーみー

Spelling Suggestions for JavaScript

.tsファイルで存在しない変数名を書いていて,それが既存の変数名と似ている場合,「もしかして」的にサジェストを出してくれる機能があるが,.jsファイル内でもスペルミスを検出してくれるようになった.

const message = "momi momi...";

// Could not find name 'massage'. Did you mean 'message'? ts(2570)
console.log(massage);

configファイルなどJSを回避できない場面はいまだ多いのでありがたい.

おーみーおーみー

Abstract Properties Do Not Allow Initializers

破壊的変更.抽象クラスの abstract プロパティを初期化するとコンパイルエラーになる.そもそも以前からこの初期化はトランスパイル時に消えていたようだ.

abstract class K {
  // Property 'v' cannot have an initializer because it is marked abstract. ts(1267)
  abstract v = 3;
}
おーみーおーみー

Inlay Hints

https://github.com/microsoft/TypeScript/pull/42089

VS CodeでRustを書いたときに出るような薄い文字での型ヒントを追加しようとしているみたい.型パズルと格闘するときなど,型の内容をチェックするのにカーソルをホバーしなくて済むのがうれしそう.


https://devblogs.microsoft.com/typescript/announcing-typescript-4-4-beta/#inlay-hints より引用

VS Code側での対応は完了 (https://github.com/microsoft/vscode/pull/113412) しており,Insidersビルドで利用可能.デフォルトでは表示されず,設定からそれぞれの表示を有効化する必要がある.とりあえず全部有効化したい場合はsettings.jsonを以下のようにする.

settings.json
{
    "typescript.inlayHints.parameterNames.enabled": "all",
    "typescript.inlayHints.enumMemberValues.enabled": true,
    "typescript.inlayHints.functionLikeReturnTypes.enabled": true,
    "typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": true,
    "typescript.inlayHints.parameterTypes.enabled": true,
    "typescript.inlayHints.propertyDeclarationTypes.enabled": true,
    "typescript.inlayHints.variableTypes.enabled": true,
}
おーみーおーみー

mastermain

4.4とは直接関係ないが,デフォルトブランチがmasterからmainに改名された.

おーみーおーみー

Class Static Blocks

https://github.com/tc39/proposal-class-static-block

今回のTC39 Proposal枠.

https://github.com/microsoft/TypeScript/pull/43370

なぜかBetaのアナウンスに含まれていなかったようで,
https://sosukesuzuki.dev/posts/stage-3-class-static-blocks/
を読んで初めて気が付いた.

クラス内に static から始まるブロックを書くことができる.このブロックに書かれたコードはクラスの定義・評価時に実行される.

class C {
  static {
    console.log('hello!');
  }
}

const c = class {
  static {
    console.log('hello!');
  }
};

クラス定義のそばに即時関数式が並んでると考えるとよい.実際そのようにトランスパイルされる.

トランスパイル結果くん.js
class CC {
}
(() => {
    console.log('hello!');
})();

// ↓クラス式が評価されるタイミングで実行されることがわかる
const c = (_a = class {
    },
    (() => {
        console.log('hello!');
    })(),
    _a);

class の中なのでプライベート (#) なプロパティ/メソッドにもアクセスできる.static なプロパティはもちろん,インスタンスのプロパティでもかまわない.

let getFooOf: (k: K) => number;
let setFooOf: (k: K, value: number) => void;

class K {
  #foo = 42;
  static {
    getFooOf = k => k.#foo;
    setFooOf = (k, value) => k.#foo = value;
    // この中でthisはK
    console.log(new this().#foo);
  }
}

const k = new K();

console.log(getFooOf!(k));

// let変数への代入は必ず行われるのでここで未初期化のコンパイルエラーが出るのは正しくない気がする.ワークアラウンドとして!をつけている
setFooOf!(k, 3234);
console.log(getFooOf!(k));

Betaの不具合として,クラス式のStatic Blockからクラス自身を参照できない.

https://github.com/microsoft/TypeScript/issues/44872

(class X {
  static msg = "wryyy";
  static {
    // ここでX is not definedになる
    console.log(X.msg);
  }
})

また,static {} 内のコードが制御フロー解析の対象にならないというバグを見つけたので報告しておいた.

https://github.com/microsoft/TypeScript/issues/44949

このスクラップは2021/12/15にクローズされました