Union 型を含むオブジェクト型を代入するときに遭遇しうるTypeScript型チェックの制限について

2021/09/30
このエントリーをはてなブックマークに追加

今回の記事を書くきっかけになったのは、以下のような TypeScript のコードを書いたときに遭遇した型エラーです。

// 例1:エラーにならない例

type T =
  | ['type01']
  | ['type02']
  ...

  | ['type25'];

type S = ['type01' | 'type02' | ... | 'type25'];

declare let t: T;
declare let s: S;

// これは通る
s = t;
t = s;
// 例2:エラーになる例

type T =
  | ['type01']
  | ['type02']
  ...

  | ['type26']; // 追加

type S = ['type01' | 'type02' | ... | 'type26']; // 追加


declare let t: T;
declare let s: S;

// これは通る
s = t;

// これはエラー!
// @ts-expect-error
t = s; // Type 'S' is not assignable to type 'T'.

TS Playground – An online editor for exploring TypeScript and JavaScript

それぞれの例における TS は、見た目は異なるものの同等の型を表しているはずで、実際 t = ss = t という代入文は、要素数が少ないうちは普通に型チェックは通ります。

ところが、この例の S を 25 要素から 26 要素の Union に増やした瞬間、 t = s という代入文が型エラーになってしまう、というのが冒頭に示した例の内容です。

いくつか関連する issue も見つけたのですが、今回はこの原因を TypeScript のソースコードを読んでより詳しく探ってみることにします(といっても、自分は TypeScript のコンパイラ実装を読むのは初めてなので、断片的な理解しかできませんでしたが…)。


TypeScript の型チェックのロジックは

https://github.com/microsoft/TypeScript/blob/v4.4.3/src/compiler/checker.ts

に書かれているようです(本記事では v4.4.3 のものを参照しました)。

冒頭の例の

t = s;

という代入文の型チェックがどのように走るかトレースしてみると、

checkBinaryLikeExpressionWorker
 -> checkAssignmentOperator
 -> checkTypeAssignableToAndOptionallyElaborate
 -> checkTypeRelatedToAndOptionallyElaborate
 -> checkTypeRelatedTo
 -> isRelatedTo
 -> recursiveTypeRelatedTo
 -> structuredTypeRelatedTo
 -> structuredTypeRelatedToWorker

という順に関数が呼ばれることが分かります。

関数名に "is related to" という述語が頻繁に現れていますが、これについての説明は checkTypeRelatedTo 関数のドキュメントに以下のように書いてあります。

/**
 * Checks if 'source' is related to 'target' (e.g.: is a assignable to).
 * @param source The left-hand-side of the relation.
 * @param target The right-hand-side of the relation.
 * @param relation The relation considered. One of 'identityRelation', 'subtypeRelation', 'assignableRelation', or 'comparableRelation'.
 * Used as both to determine which checks are performed and as a cache of previously computed results.
 * @param errorNode The suggested node upon which all errors will be reported, if defined. This may or may not be the actual node used.
 * @param headMessage If the error chain should be prepended by a head message, then headMessage will be used.
 * @param containingMessageChain A chain of errors to prepend any new errors found.
 * @param errorOutputContainer Return the diagnostic. Do not log if 'skipLogging' is truthy.
 */

どうやら、checkTypeRelatedTo 関数は sourcetarget の 2 つの型について、 identityRelation , subtypeRelation , assignableRelation , comparableRelation の 4 つのうちいずれかの型関係を判定する関数のようです。代入文のチェックでは assignableRelation になるんでしょうか。

t = s という代入文における s の型が sourcet の型が target になります。
最初自分が読んだ時、@param sourceThe left-hand-side of the relation という説明を見て私は t = s という代入文における左辺のことかと勘違いして混乱してしまいましたが、これは "is assignable to" などの型関係における左辺という意味であって、ソースコード中の位置関係とは必ずしも一致しないようです(代入文などでは左右反転しますが、たとえば A extends B のような条件型では同じ位置関係になります)。

試しに、

type A = 1 | 2 | 3;
type B = 1 | 2;
export declare let a: A;
export declare let b: B;
a = b; // ok

というコードを tsc に投げて dump してみると以下のようになり、 source が代入文の右辺、target が左辺であることが確認できました。

source: Type {
  flags: 1048576,
  id: 84,
  objectFlags: 65536,
  types: [ [Type], [Type] ],
  origin: undefined,
  aliasSymbol: Symbol {
    flags: 524288,
    escapedName: 'B',
    declarations: [Array],
    valueDeclaration: undefined,
    id: 9,
    mergeId: undefined,
    parent: undefined,
    isReferenced: 788968
  },
  aliasTypeArguments: undefined
},

target: Type {
  flags: 1048576,
  id: 83,
  objectFlags: 65536,
  types: [ [Type], [Type], [Type] ],
  origin: undefined,
  aliasSymbol: Symbol {
    flags: 524288,
    escapedName: 'A',
    declarations: [Array],
    valueDeclaration: undefined,
    id: 8,
    mergeId: undefined,
    parent: undefined,
    isReferenced: 788968
  },
  aliasTypeArguments: undefined
}

先へ進み、 structuredTypeRelatedToWorker の中を見てみると、 sourcetarget の型の種類によって様々な分岐が行われています。

冒頭の例 1 や例 2 では st

type T =
  | ['type01']
  | ['type02']
  ...

  | ['type25'];

type S = ['type01' | 'type02' | ... | 'type25'];

という型なので

// If S is an object type and T is a discriminated union, S may be related to T if
// there exists a constituent of T for every combination of the discriminants of S
// with respect to T. We do not report errors here, as we will use the existing
// error result from checking each constituent of the union.
if (
    source.flags & (TypeFlags.Object | TypeFlags.Intersection) &&
    target.flags & TypeFlags.Union
) {
    const objectOnlyTarget = extractTypesOfKind(
        target,
        TypeFlags.Object | TypeFlags.Intersection | TypeFlags.Substitution
    );
    if (objectOnlyTarget.flags & TypeFlags.Union) {
        const result = typeRelatedToDiscriminatedType(
            source,
            objectOnlyTarget as UnionType
        );
        if (result) {
            return result;
        }
    }
}

という分岐に入り、ここで関数 typeRelatedToDiscriminatedType が呼ばれます。

関数 typeRelatedToDiscriminatedType には以下のようなコメントが書かれています。

1. Generate the combinations of discriminant properties & types 'source' can satisfy.
   a. If the number of combinations is above a set limit, the comparison is too complex.
2. Filter 'target' to the subset of types whose discriminants exist in the matrix.
   a. If 'target' does not satisfy all discriminants in the matrix, 'source' is not related.
3. For each type in the filtered 'target', determine if all non-discriminant properties of 'target' are related to a property in 'source'.

NOTE: See ~/tests/cases/conformance/types/typeRelationships/assignmentCompatibility/assignmentCompatWithDiscriminatedUnion.ts for examples.

1-a. の "If the number of combinations is above a set limit, the comparison is too complex." という説明が関係していそうです。

コードを読んでいくと、

// Though we could compute the number of combinations as we generate
// the matrix, this would incur additional memory overhead due to
// array allocations. To reduce this overhead, we first compute
// the number of combinations to ensure we will not surpass our
// fixed limit before incurring the cost of any allocations:
let numCombinations = 1;
for (const sourceProperty of sourcePropertiesFiltered) {
    numCombinations *= countTypes(getNonMissingTypeOfSymbol(sourceProperty));
    if (numCombinations > 25) {
        // We've reached the complexity limit.
        tracing?.instant(
            tracing.Phase.CheckTypes,
            'typeRelatedToDiscriminatedType_DepthLimit',
            { sourceId: source.id, targetId: target.id, numCombinations }
        );
        return Ternary.False;
    }
}

というコードが見つかります。

試しにこの 25 という数値を 26 に書き換えて tsc を実行すると、先ほどの例で型エラーが消えることが確認できました。今回の型エラーは結局この Union 型の複雑度チェックで弾かれるのが原因であることが分かりました。

コメントにあった tests 配下の assignmentCompatWithDiscriminatedUnion.ts も見てみると、以下のようなテストケースが書かれています。

// Maximum discriminant combinations
namespace Example5 {
    // NOTE: The maximum number of discriminant type combinations is currently 25.
    //       3 discriminant properties with 3 types a piece
    //       is 27 possible combinations.
    type N = 0 | 1 | 2;
    type S = { a: N; b: N; c: N };
    type T =
        | { a: 0; b: N; c: N }
        | { a: 1; b: N; c: N }
        | { a: 2; b: N; c: N }
        | { a: N; b: 0; c: N }
        | { a: N; b: 1; c: N }
        | { a: N; b: 2; c: N }
        | { a: N; b: N; c: 0 }
        | { a: N; b: N; c: 1 }
        | { a: N; b: N; c: 2 };
    declare let s: S;
    declare let t: T;

    // S *should* be assignable but the number of
    // combinations is too complex.
    t = s;
}

おそらくですが、右辺 S が Union 型を含むレコード型やタプル型などの場合、 S が「そのまま」左辺 T に代入できる形ではない場合は、Union が一番外に来る標準形のようなもの(下に示したようなもの)への展開を考えて、それらがすべて T に代入可能か調べる必要があるのですが、その展開した Union 型のサイズが掛け算で増えてしまうため、展開後のサイズが 25 を超えてしまうことが分かった場合は展開を避けるように type checker が制限しているようです。

assignmentCompatWithDiscriminatedUnion.ts の例では、t = s のチェックは S を展開した型である

  { a: 0, b: 0, c: 0 }
| { a: 0, b: 0, c: 1 }
| { a: 0, b: 0, c: 2 }
| { a: 0, b: 1, c: 0 }
| { a: 0, b: 1, c: 1 }
| { a: 0, b: 1, c: 2 }
| { a: 0, b: 2, c: 0 }
| { a: 0, b: 2, c: 1 }
| { a: 0, b: 2, c: 2 }
| { a: 1, b: 0, c: 0 }
| { a: 1, b: 0, c: 1 }
| { a: 1, b: 0, c: 2 }
| { a: 1, b: 1, c: 0 }
| { a: 1, b: 1, c: 1 }
| { a: 1, b: 1, c: 2 }
| { a: 1, b: 2, c: 0 }
| { a: 1, b: 2, c: 1 }
| { a: 1, b: 2, c: 2 }
| { a: 2, b: 0, c: 0 }
| { a: 2, b: 0, c: 1 }
| { a: 2, b: 0, c: 2 }
| { a: 2, b: 1, c: 0 }
| { a: 2, b: 1, c: 1 }
| { a: 2, b: 1, c: 2 }
| { a: 2, b: 2, c: 0 }
| { a: 2, b: 2, c: 1 }
| { a: 2, b: 2, c: 2 }

の各要素(a とします)について isRelatedTo(a, T) を呼び出して and を取ることで ST へ代入可能か判定できそうですが、そのときに S のサイズが 27 なので制限を超えてしまうため型チェッカーが false を返してしまう、ということです。

TS Playground – An online editor for exploring TypeScript and JavaScript

この例を色々いじってみると分かるのですが、 N = 0 | 1 のようにしてサイズを小さくすれば 25 以下チェックにひっかからないのでエラーになりませんし、 T の Union のメンバーに { a: N, b: N, c: N } を追加してみると型チェックが通ったりするので、中身の Union 型を展開するまでもなく代入可能であると判定できる場合については Union 型の展開を避けて比較できるようになっていることも分かります(賢い!)。

さっきは読み飛ばしましたが、 numCombinations のチェックは source に対してのみ行われています。これは自然なことで、ここでの Union 型の展開は source に対してのみ行えばよく、 target は(少なくともここでは)展開しなくてよいからです。


t = s はダメだけど s = t は通るのはなぜか、も気になるので調べてみます。

こちらは typeRelatedToDiscriminatedType 関数が呼ばれておらず、同じ関数 structuredTypeRelatedToWorker の中の一つ手前の分岐

// Even if relationship doesn't hold for unions, intersections, or generic type references,
// it may hold in a structural comparison.
// In a check of the form X = A & B, we will have previously checked if A relates to X or B relates
// to X. Failing both of those we want to check if the aggregation of A and B's members structurally
// relates to X. Thus, we include intersection types on the source side here.
if (
    source.flags & (TypeFlags.Object | TypeFlags.Intersection) &&
    target.flags & TypeFlags.Object
) {
    // Report structural errors only if we haven't reported any errors yet
    const reportStructuralErrors =
        reportErrors &&
        errorInfo === saveErrorInfo.errorInfo &&
        !sourceIsPrimitive;
    result = propertiesRelatedTo(
        source,
        target,
        reportStructuralErrors,
        /*excludedProperties*/ undefined,
        intersectionState
    );
    if (result) {
        result &= signaturesRelatedTo(
            source,
            target,
            SignatureKind.Call,
            reportStructuralErrors
        );
        if (result) {
            result &= signaturesRelatedTo(
                source,
                target,
                SignatureKind.Construct,
                reportStructuralErrors
            );
            if (result) {
                result &= indexSignaturesRelatedTo(
                    source,
                    target,
                    sourceIsPrimitive,
                    reportStructuralErrors,
                    intersectionState
                );
            }
        }
    }
    if (varianceCheckFailed && result) {
        errorInfo = originalErrorInfo || errorInfo || saveErrorInfo.errorInfo; // Use variance error (there is no structural one) and return false
    } else if (result) {
        return result;
    }
}

に入っているようです。力尽きてしまったのでここも中を細かく読めていませんが、先ほどの typeRelatedToDiscriminatedType は呼ばれておらず、Union 型の展開を避ける形での比較をしていそうです(そもそも T はこれ以上展開しようが無い型ではあります)。

まとめ

  • TypeScript において、Union 型を内部に含むタプルやレコード型などを別の型に代入可能か判定するとき、 Union 型を内部に含まないように展開しなければ比較できないがそうすると要素数が 25 を超える Union 型になってしまう場合は、代入できるはずのところで代入できないと判定されることがある。これは型チェッカーのパフォーマンスを落とさないために設けられた意図的な制限である。
  • Union 型を展開しなくても比較可能な場面では、TypeScript が賢く Union 型の展開を回避している場合がある(このため、実用上この制限に遭遇することはあまりない)。

以上、 noshiro が書きました。

その他の記事

Other Articles

2022/06/03
拡張子に Web アプリを関連付ける File Handling API の使い方

2022/03/22
<selectmenu> タグできる子; <select> に代わるカスタマイズ可能なドロップダウンリスト

2022/03/02
Java 15 のテキストブロックを横目に C# 11 の生文字列リテラルを眺めて ECMAScript String dedent プロポーザルを想う

2021/10/13
Angularによる開発をできるだけ型安全にするためのKabukuでの取り組み

2021/09/30
さようなら、Node.js

2021/09/16
[ECMAScript] Pipe operator 論争まとめ – F# か Hack か両方か

2021/07/05
TypeScript v4.3 の機能を使って immutable ライブラリの型付けを頑張る

2021/06/25
Denoでwasmを動かすだけの話

2021/05/18
DOMMatrix: 2D / 3D 変形(アフィン変換)の行列を扱う DOM API

2021/03/29
GoのWASMがライブラリではなくアプリケーションであること

2021/03/26
Pythonプロジェクトの共通のひな形を作る

2021/03/25
インラインスタイルと Tailwind CSS と Tailwind CSS 入力補助ライブラリと Tailwind CSS in JS

2021/03/23
Serverless NEGを使ってApp Engineにカスタムドメインをワイルドカードマッピング

2021/01/07
esbuild の機能が足りないならプラグインを自作すればいいじゃない

2020/08/26
TypeScriptで関数の部分型を理解しよう

2020/06/16
[Web フロントエンド] esbuild が爆速すぎて webpack / Rollup にはもう戻れない

2020/03/19
[Web フロントエンド] Elm に心折れ Mint に癒しを求める

2020/02/28
さようなら、TypeScript enum

2020/02/14
受付のLooking Glassに加えたひと工夫

2020/01/28
カブクエンジニア開発合宿に行ってきました 2020冬

2020/01/30
Renovateで依存ライブラリをリノベーションしよう 〜 Bitbucket編 〜

2019/12/27
Cloud Tasks でも deferred ライブラリが使いたい

2019/12/25
*, ::before, ::after { flex: none; }

2019/12/21
Top-level awaitとDual Package Hazard

2019/12/20
Three.jsからWebGLまで行きて帰りし物語

2019/12/18
Three.jsに入門+手を検出してAR.jsと組み合わせてみた

2019/12/04
WebXR AR Paint その2

2019/11/06
GraphQLの入門書を翻訳しました

2019/09/20
Kabuku Connect 即時見積機能のバックエンド開発

2019/08/14
Maker Faire Tokyo 2019でARゲームを出展しました

2019/07/25
夏休みだョ!WebAssembly Proposal全員集合!!

2019/07/08
鵜呑みにしないで! —— 書籍『クリーンアーキテクチャ』所感 ≪null 篇≫

2019/07/03
W3C Workshop on Web Games参加レポート

2019/06/28
TypeScriptでObject.assign()に正しい型をつける

2019/06/25
カブクエンジニア開発合宿に行ってきました 2019夏

2019/06/21
Hola! KubeCon Europe 2019の参加レポート

2019/06/19
Clean Resume きれいな環境できれいな履歴書を作成する

2019/05/20
[Web フロントエンド] 状態更新ロジックをフレームワークから独立させる

2019/04/16
C++のenable_shared_from_thisを使う

2019/04/12
OpenAPI 3 ファーストな Web アプリケーション開発(Python で API 編)

2019/04/08
WebGLでレイマーチングを使ったCSGを実現する

2019/03/29
その1 Jetson TX2でk3s(枯山水)を動かしてみた

2019/04/02
『エンジニア採用最前線』に感化されて2週間でエンジニア主導の求人票更新フローを構築した話

2019/03/27
任意のブラウザ上でJestで書いたテストを実行する

2019/02/08
TypeScript で “radian” と “degree” を間違えないようにする

2019/02/05
Python3でGoogle Cloud ML Engineをローカルで動作する方法

2019/01/18
SIGGRAPH Asia 2018 参加レポート

2019/01/08
お正月だョ!ECMAScript Proposal全員集合!!

2019/01/08
カブクエンジニア開発合宿に行ってきました 2018秋

2018/12/25
OpenAPI 3 ファーストな Web アプリケーション開発(環境編)

2018/12/23
いまMLKitカスタムモデル(TF Lite)は使えるのか

2018/12/21
[IoT] Docker on JetsonでMQTTを使ってCloud IoT Coreと通信する

2018/12/11
TypeScriptで実現する型安全な多言語対応(Angularを例に)

2018/12/05
GASでCompute Engineの時間に応じた自動停止/起動ツールを作成する 〜GASで簡単に好きなGoogle APIを叩く方法〜

2018/12/02
single quotes な Black を vendoring して packaging

2018/11/14
3次元データに2次元データの深層学習の技術(Inception V3, ResNet)を適用

2018/11/04
Node Knockout 2018 に参戦しました

2018/10/24
SIGGRAPH 2018参加レポート-後編(VR/AR)

2018/10/11
Angular 4アプリケーションをAngular 6に移行する

2018/10/05
SIGGRAPH 2018参加レポート-特別編(VR@50)

2018/10/03
Three.jsでVRしたい

2018/10/02
SIGGRAPH 2018参加レポート-前編

2018/09/27
ズーム可能なSVGを実装する方法の解説

2018/09/25
Kerasを用いた複数入力モデル精度向上のためのTips

2018/09/21
競技プログラミングの勉強会を開催している話

2018/09/19
Ladder Netwoksによる半教師あり学習

2018/08/10
「Maker Faire Tokyo 2018」に出展しました

2018/08/02
Kerasを用いた複数時系列データを1つの深層学習モデルで学習させる方法

2018/07/26
Apollo GraphQLでWebサービスを開発してわかったこと

2018/07/19
【深層学習】時系列データに対する1次元畳み込み層の出力を可視化

2018/07/11
きたない requirements.txt から Pipenv への移行

2018/06/26
CSS Houdiniを味見する

2018/06/25
不確実性を考慮した時系列データ予測

2018/06/20
Google Colaboratory を自分のマシンで走らせる

2018/06/18
Go言語でWebAssembly

2018/06/15
カブクエンジニア開発合宿に行ってきました 2018春

2018/06/08
2018 年の tree shaking

2018/06/07
隠れマルコフモデル 入門

2018/05/30
DASKによる探索的データ分析(EDA)

2018/05/10
TensorFlowをソースからビルドする方法とその効果

2018/04/23
EGLとOpenGLを使用するコードのビルド方法〜libGLからlibOpenGLへ

2018/04/23
技術書典4にサークル参加してきました

2018/04/13
Python で Cura をバッチ実行するためには

2018/04/04
ARCoreで3Dプリント風エフェクトを実現する〜呪文による積層造形映像制作の舞台裏〜

2018/04/02
深層学習を用いた時系列データにおける異常検知

2018/04/01
音声ユーザーインターフェースを用いた新方式積層造形装置の提案

2018/03/31
Container builderでコンテナイメージをBuildしてSlackで結果を受け取る開発スタイルが捗る

2018/03/23
ngUpgrade を使って AngularJS から Angular に移行

2018/03/14
Three.jsのパフォーマンスTips

2018/02/14
C++17の新機能を試す〜その1「3次元版hypot」

2018/01/17
時系列データにおける異常検知

2018/01/11
異常検知の基礎

2018/01/09
three.ar.jsを使ったスマホAR入門

2017/12/17
Python OpenAPIライブラリ bravado-core の発展的な使い方

2017/12/15
WebAssembly(wat)を手書きする

2017/12/14
AngularJS を Angular に移行: ng-annotate 相当の機能を TypeScrpt ファイルに適用

2017/12/08
Android Thingsで4足ロボットを作る ~ Android ThingsとPCA9685でサーボ制御)

2017/12/06
Raspberry PIとDialogflow & Google Cloud Platformを利用した、3Dプリンターボット(仮)の開発 (概要編)

2017/11/20
カブクエンジニア開発合宿に行ってきました 2017秋

2017/10/19
Android Thingsを使って3Dプリント戦車を作ろう ① ハードウェア準備編

2017/10/13
第2回 魁!! GPUクラスタ on GKE ~PodからGPUを使う編~

2017/10/05
第1回 魁!! GPUクラスタ on GKE ~GPUクラスタ構築編~

2017/09/13
「Maker Faire Tokyo 2017」に出展しました。

2017/09/11
PyConJP2017に参加しました

2017/09/08
bravado-coreによるOpenAPIを利用したPythonアプリケーション開発

2017/08/23
OpenAPIのご紹介

2017/08/18
EuroPython2017で2名登壇しました。

2017/07/26
3DプリンターでLチカ

2017/07/03
Three.js r86で何が変わったのか

2017/06/21
3次元データへの深層学習の適用

2017/06/01
カブクエンジニア開発合宿に行ってきました 2017春

2017/05/08
Three.js r85で何が変わったのか

2017/04/10
GCPのGPUインスタンスでレンダリングを高速化

2017/02/07
Three.js r84で何が変わったのか

2017/01/27
Google App EngineのFlexible EnvironmentにTmpfsを導入する

2016/12/21
Three.js r83で何が変わったのか

2016/12/02
Three.jsでのクリッピング平面の利用

2016/11/08
Three.js r82で何が変わったのか

2016/12/17
SIGGRAPH 2016 レポート

2016/11/02
カブクエンジニア開発合宿に行ってきました 2016秋

2016/10/28
PyConJP2016 行きました

2016/10/17
EuroPython2016で登壇しました

2016/10/13
Angular 2.0.0ファイナルへのアップグレード

2016/10/04
Three.js r81で何が変わったのか

2016/09/14
カブクのエンジニアインターンシッププログラムについての詩

2016/09/05
カブクのエンジニアインターンとして3ヶ月でやった事 〜高橋知成の場合〜

2016/08/30
Three.js r80で何が変わったのか

2016/07/15
Three.js r79で何が変わったのか

2016/06/02
Vulkanを試してみた

2016/05/20
MakerGoの作り方

2016/05/08
TensorFlow on DockerでGPUを使えるようにする方法

2016/04/27
Blenderの3DデータをMinecraftに送りこむ

2016/04/20
Tensorflowを使ったDeep LearningにおけるGPU性能調査

→
←

関連職種

Recruit

→
←

お客様のご要望に「Kabuku」はお応えいたします。
ぜひお気軽にご相談ください。

お電話でも受け付けております
03-6380-2750
営業時間:09:30~18:00
※土日祝は除く