iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

TypeScript(GitHubのwiki)のパフォーマンスに関する内容を読んでみた

こんにちは! 株式会社iimonでエンジニアをやっているあめくです! 最近TypeScriptを触る機会が増え、まだまだ勉強中ですが日々切磋琢磨しながら頑張っています!💪

今回はTypeScriptのWikiに記載されてるパフォーマンスに関する内容を読んで コンパイルしやすいコード の書き方について学んでみました!

参考URL: https://github.com/microsoft/TypeScript/wiki/Performance

※ 注意点
- プロジェクトやチームごとにコーディングルールが異なるため、あくまで参考としてご覧ください。
- 今回取り上げるのは コンパイル時のパフォーマンス」 であり、実行時のパフォーマンス ではありません。

Preferring Interfaces Over Intersections (インターセクションよりもインターフェイスを優先する)

まず1つ目の内容は、インターフェースの拡張(extends)と型エイリアスの交差型(&)の違いについて説明されています。 2つ以上の型を組み合わせる場合、インターフェースを拡張する方法と型エイリアスで交差させる方法のどちらかを選ぶか考える必要があります。 特にコンパイルのパフォーマンスや型の解釈への影響に違いがあり、交差型(インターセクション型)よりも、インターフェースを使う方がパフォーマスが良いとされているそうです。

交差型(インターセクション型)とは T & U のように記述し、「T型でありかつU型でもある値」を表す型です。

インターフェースで型を組み合わせる場合

インターフェースは単一のフラットなオブジェクト型を作成し、プロパティの競合を検出できるそうです。

interface Bar {
  barProp: string;
}

interface Baz {
  bazProp: number;
}

// `Foo` は `Bar` と `Baz` を継承(extends)している
interface Foo extends Bar, Baz {
  someProp: boolean;
}

const foo: Foo = {
  barProp: "hello",
  bazProp: 123,
  someProp: true,
};

interface Foo extends Bar, Baz により、FooBarBaz のプロパティを統合し、1つのフラットなオブジェクト型になります。 もし BarBaz に同じプロパティがあり、それぞれの型が異なる場合場合はエラーとして検出できます。 また型の関係をキャッシュするため、パフォーマンスの面でもメリットがあります。

エイリアスで型を組み合わせる場合

エイリアスの交差型(&)は、複数の型をそのまま結合します。 しかし、同じプロパティ名で異なる型がある場合、自動的にエラーにはならず型がneverになってしまうことがあります。

type Bar = {
  barProp: string;
};

type Baz = {
  bazProp: number;
};

// `Bar` と `Baz` を & で結合
type Foo = Bar & Baz & {
  someProp: boolean;
};

const foo: Foo = {
  barProp: "hello",
  bazProp: 123,
  someProp: true,
};

この場合、FooBarBaz のプロパティを統合し、さらに someProp: boolean を追加した型になります。これは extends と似たような動作をしますが、プロパティの競合チェックは行われません。

  • 交差型(&)で競合が発生すると never になる例
type A = { prop: string };
type B = { prop: number };

type C = A & B; // 競合するため `prop: never` になる

const test: C = {
  prop: "hello", // ❌ エラー: never 型には代入できない
};

Aの propstring ですが、Bの propnumber です。この2つを交差型で結合すると、prop: string & number となります。しかし、stringnumber を同時に満たす型は存在しないため、prop の型は never になり、結果としてエラーになります。

まとめると交差型(&)を使用すると、プロパティの型が競合した場合にneverになってしまうことがあります。 一方、インターフェースを使用すれば、競合をエラーとして検出できるため、原因が直感的に理解しやすく、デバッグもしやすくなります。 そのため、型の拡張を考慮する場合は、型エイリアスよりもインターフェースを使用する方がバグを防ぎやすいとされています。

またインターフェースは内部的にキャッシュされるため、型エイリアスよりもパフォーマンスが良いとされてるみたいです。

Using Type Annotations (型アノテーションの使用)

2つ目の内容は、アノテーションについて説明されています。

アノテーション(特に戻り値の型)を明示的に指定すると、コンパイラの負担が軽減されてコンパイル速度が向上されるそうです。 理由としては、名前付きの型はコンパクトで、コンパイラ型推論にかける処理時間を削減できるためだそうです。 すべてのコードで型アノテーションが必須というわけではありませんが、コンパイルが遅い箇所では有効な最適化手法となる可能性がありそうです。

具体的な例

TypeScriptで型をエクスポートする際、import("./foo").Result のような動的な型参照が発生すると、コンパイラが余計な処理を行うため、コンパイル速度が遅くなるそうです。これを具体的に見てみましょう。

  • 動的な型参照が発生する場合
// foo.ts
export interface Result {
    headers: any;
    body: string;
}

export async function makeRequest(): Promise<Result> {
    throw new Error("unimplemented");
}

// bar.ts
import { makeRequest } from "./foo";

export function doStuff() {
    return makeRequest();
}

このコードをコンパイルすると、bar.d.ts は以下のように生成されます。

export declare function doStuff(): Promise<import("./foo").Result>;

ここで発生している問題は、import("./foo").Result のような動的な型参照です。

TypeScriptは、Result 型がどこに定義されているかを計算し、新たに参照情報を作成する必要があります。この処理がコンパイル時に発生するため処理が重くなり、コンパイル速度に悪影響を与えることがあるそうです。

解決策: 型を明示的にインポートする

動的な型参照を避け、型を明示的にインポートすることで、コンパイル速度の改善が期待できます。

以下のように、Result 型を直接インポートする形に変更します。

import { Result } from "./foo";

export function doStuff(): Promise<Result> {
    return makeRequest();
}

これにより、 bar.d.ts は次のように生成されます。

import { Result } from "./foo";
export declare function doStuff(): Promise<Result>;

この変更により、TypeScriptは動的な型参照を行う必要がなくなり、型の参照を直接インポートして使うことで、パフォーマンスが向上します。

なぜパフォーマンスが改善するのか?

TypeScriptは、型がそのファイル内で直接アクセスできるか、またはimport(...)を使って他のファイルから型を参照する必要があるかを確認します。

もしimport(...)を使う場合、どのファイルからその型を取得するかを計算して、型を正しく参照できるようにします。その後、その参照情報を作成し最終的にコードに反映させます。

大規模なプロジェクトでは、この計算や参照作成の処理が何度も繰り返されるため、コンパイル速度が遅くなってしまうことがあるそうです。

Preferring Base Types Over Unions (ユニオンよりも基底型を優先する)

3つ目の内容は、ユニオン型の利点と問題点について説明されています。

ユニオン型とは

ユニオン型は、複数の型を1つの型として扱うことができるTypeScriptの機能です。例えば、以下のように、WeekdayScheduleWeekendSchedule の2つの異なる型を1つの引数に渡すことができます。

interface WeekdaySchedule {
  day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday";
  wake: Time;
  startWork: Time;
  endWork: Time;
  sleep: Time;
}

interface WeekendSchedule {
  day: "Saturday" | "Sunday";
  wake: Time;
  familyMeal: Time;
  sleep: Time;
}

declare function printSchedule(schedule: WeekdaySchedule | WeekendSchedule);

このように、ユニオン型を使うことで、異なる型を1つの関数で柔軟に扱うことができます。上記の例では、WeekdaySchedule または WeekendSchedule のいずれかが printSchedule 関数に渡されることになります。

ユニオン型のパフォーマンス問題

ユニオン型にはパフォーマンスに関する問題があると言われています。

特に、ユニオン型に多くの要素が含まれている場合(オブジェクト型のユニオン型)、コンパイラはその型のすべての要素を比較する必要があり、計算量が急激に増えることがあるようです。この計算は、ペアごとの比較が行われるため、最悪の場合、計算量が二次関数的に増える(O(n²))ことになります。

そのため計算処理が重くなり、コンパイル速度が遅くなることがあるかもしれません。

ユニオン型を避けるためのアプローチ

ユニオン型がパフォーマンスに悪影響を与える場合、型の階層を作成することで、問題を回避できる可能性があります。ユニオン型ではなく、継承を利用する方法です。

例えば、WeekdayScheduleWeekendSchedule をユニオン型で組み合わせる代わりに、共通の型 Schedule を作り、それぞれの型がそれを継承するようにします。

interface Schedule {
  day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
  wake: Time;
  sleep: Time;
}

interface WeekdaySchedule extends Schedule {
  day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday";
  startWork: Time;
  endWork: Time;
}

interface WeekendSchedule extends Schedule {
  day: "Saturday" | "Sunday";
  familyMeal: Time;
}

declare function printSchedule(schedule: Schedule);

このように、WeekdayScheduleWeekendSchedule が共通の Schedule 型を継承することで、ユニオン型(WeekdaySchedule | WeekendSchedule)ではなく、単一の Schedule 型を関数に渡せるようになります。

より具体的な例

ユニオン型を避けて継承を使う理由を実例を交えて説明します。たとえば、DOM要素を扱う場合、DivElementImgElement など、さまざまな要素の型があるとき、それぞれの型が共通の HtmlElement 基底型を継承する方が効率的です。

  • ユニオン型を使う場合
type HtmlElement = DivElement | ImgElement | ButtonElement | /* ... */;
  • 継承を使う場合
interface HtmlElement {
  id: string;
  className: string;
  // 共通のプロパティ
}

interface DivElement extends HtmlElement {
  type: "div";
  // Div の特有のプロパティ
}

interface ImgElement extends HtmlElement {
  type: "img";
  // Img の特有のプロパティ
}

ユニオン型は異なる型を1つの型として扱える便利な機能ですが、多くの要素を含む場合、コンパイラが比較を行う必要があり、パフォーマンスが低下する可能性がありそうです。

継承を使うことで型の階層を作り、ユニオン型の冗長性を避け、コンパイルのパフォーマンスを改善することができそうですね。

大規模なプロジェクトや型が多い場合は、ユニオン型を避けて継承を使うアプローチが有効になる場面もありそうでした。

Naming Complex Types (複雑な型の命名)

4つ目の内容は、複雑な型について説明されています。

TypeScriptでは、複雑な型を関数の戻り値に直接書くと、パフォーマンスに影響が出ることがあるようです。以下の例を見てみましょう。

interface SomeType<T> {
    foo<U>(x: U):
        U extends TypeA<T> ? ProcessTypeA<U, T> :
        U extends TypeB<T> ? ProcessTypeB<U, T> :
        U extends TypeC<T> ? ProcessTypeC<U, T> :
        U;
}

このコードの問題点としては、foo を呼ぶたびに TypeScript が条件付き型(conditional type)を再計算しなければならないことが挙げられます。さらに、SomeTypeインスタンス同士を比較する際にも、毎回その構造をチェックしなければならず、これがコンパイルコストを高くする原因となるそうです。

これを改善する方法として、型エイリアスを使うことが考えられます。具体的には、条件付き型の処理を別の型エイリアスに分けることで、コンパイラが同じ計算を繰り返さずに済むようにします。

type FooResult<U, T> =
    U extends TypeA<T> ? ProcessTypeA<U, T> :
    U extends TypeB<T> ? ProcessTypeB<U, T> :
    U extends TypeC<T> ? ProcessTypeC<U, T> :
    U;

interface SomeType<T> {
    foo<U>(x: U): FooResult<U, T>;
}

エイリアス FooResult<U, T> を使うと、条件付き型の処理を一度だけ名前付き型として定義できます。

その結果、foo を呼び出すたびに TypeScript は毎回複雑な計算を繰り返すのではなく、すでに定義した名前付き型 FooResult<U, T> を使うことになります。これにより、コンパイラは再計算を避け、処理を効率的に行えるようになり、結果としてコンパイル速度が改善される可能性があるそうです。

まとめ

今回、GitHubにあるTypeScriptのWikiを読んでみて、パフォーマンス(コンパイルに関する)を意識する上で考慮すべきことがたくさんあることに気づきました。

特に戻り値の型アノテーションについては、型推論が自動的に行われるため問題ないと思いがちですが、意図した型推論が行われない場合にパフォーマンスが低下することに驚きました。

パフォーマンスに関する内容はまだ一部しか確認できていないので、引き続き学習を深めていく必要があると感じています。また、今回は実際に試すことができませんでしたが、今後はパフォーマンス測定を行いさらに理解度を高めていければと思っています。

(もしこの内容に間違いがあればご指摘ください!🙇‍♂️)

余談

今回確認したパフォーマンスの話とは少し異なりますが、TypeScriptのGitHubにはドキュメント以外にもさまざまな情報が載っていることに気づきました。

特に、大規模開発になるとチーム内で開発者が増え、コーディング規約が定まっていないと大変なことになると感じます。

TypeScriptではコントリビューター向けのコーディングガイドラインが定められており、これらのガイドラインを参考にし自分たちのプロジェクトでもコーディング規約を策定していくのは面白いのではと感じました!

※ 注意: これらのガイドラインはあくまでTypeScriptプロジェクトに対するコントリビューター向けであり、TypeScriptを用いたサービス開発に関するガイドではない点に留意が必要です。

https://github.com/microsoft/TypeScript/wiki/Coding-guidelines

最後に

現在弊社ではエンジニアを募集しています!

この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!

iimon採用サイト / Wantedly / Green

最後まで読んでいただきありがとうございました!