Programming Field

[TypeScript] 共用体型の型の順番

TypeScriptの共用体型(Union types)は「A | B」のように「|」で型を連結した型で、どちらか一方の型であればよいことを示す型です。基本的には「|」の左側・右側どちらに型を書くかは自由であり、どの順番で書いても同じ型とみなされます。

ただし、内部処理的には順番に関して特殊なロジックがあり、VSCodeなどで型を見ると記述した順番とは異なる順番になっている場合があります。

※ TypeScript 5.1.6 時点での内容です。

TL;DR

  • TypeScript内部では、リテラル型を含むすべての型に対して id (数値)が付与されています。
  • TypeScriptは共用体型のデータを作成する際、型のリストを id 順(小さい順)で生成します。
  • 型の id は原則として出現順(やや厳密にはTypeScriptエンジンの利用者(IDEなど)による型の参照順)に振られます。
  • これにより、共用体型を構成する型がその手前に出現している場合、その型が共用体の並びの最初に来ることになります。

参考: TS Playground

上記 Playground のコード
let pre: 3;
let pre2: 5;

// マウスホバーすると「3 | 5 | 1 | 2 | 4」になる
let x: 1 | 2 | 3 | 4 | 5;

interface B { b: number; }
interface A { a: string; }

// マウスホバーすると「B | A」になる
let hoge: A | B;
// 交差型は順番通りになる
let piyo: A & B;

TypeScript内部処理における型の id

TypeScriptエンジン(tsc または tsserver)は、型を表すデータを作る際に id を付与しています。

(refs. https://github.com/microsoft/TypeScript/blob/v5.1.6/src/compiler/checker.ts 5564行目)

    function createType(flags: TypeFlags): Type {
        const result = new Type(checker, flags);
        typeCount++;
        result.id = typeCount;
        tracing?.recordType(result);
        return result;
    }

コードを見てわかるように、id は createType が呼び出されるごとに増える typeCount の値から付与されています。つまり、id は型の種類・内容に関係なくより最初に作られたものが小さい数値になります。

ちなみに、この createType は、共用体型においては以下のように使用されます。

(refs. https://github.com/microsoft/TypeScript/blob/v5.1.6/src/compiler/checker.ts 16515行目)

    // This function assumes the constituent type list is sorted and deduplicated.
    function getUnionTypeFromSortedList(types: Type[], precomputedObjectFlags: ObjectFlags, aliasSymbol?: Symbol, aliasTypeArguments?: readonly Type[], origin?: Type): Type {
        if (types.length === 0) {
            return neverType;
        }
        if (types.length === 1) {
            return types[0];
        }
        const typeKey = !origin ? getTypeListId(types) :
            origin.flags & TypeFlags.Union ? `|${getTypeListId((origin as UnionType).types)}` :
            origin.flags & TypeFlags.Intersection ? `&${getTypeListId((origin as IntersectionType).types)}` :
            `#${(origin as IndexType).type.id}|${getTypeListId(types)}`; // origin type id alone is insufficient, as `keyof x` may resolve to multiple WIP values while `x` is still resolving
        const id = typeKey + getAliasId(aliasSymbol, aliasTypeArguments);
        let type = unionTypes.get(id);
        if (!type) {
            type = createType(TypeFlags.Union) as UnionType;
            type.objectFlags = precomputedObjectFlags | getPropagatingFlagsOfTypes(types, /*excludeKinds*/ TypeFlags.Nullable);
            type.types = types;
            type.origin = origin;
            type.aliasSymbol = aliasSymbol;
            type.aliasTypeArguments = aliasTypeArguments;
            if (types.length === 2 && types[0].flags & TypeFlags.BooleanLiteral && types[1].flags & TypeFlags.BooleanLiteral) {
                type.flags |= TypeFlags.Boolean;
                (type as UnionType & IntrinsicType).intrinsicName = "boolean";
            }
            unionTypes.set(id, type);
        }
        return type;
    }

ここで、共用体を構成する各要素の型は「types」というフィールドに入るのですが、types (Type の配列) がどのように生成されているかは次の通りです。

共用体型の型リスト

共用体型のデータは、TypeScriptエンジンでは「共用体」を表す種別と「型リスト」などから構成されています。この型リストは共用体を構成する各要素の型を指すのですが、リストの生成ロジックは以下のようになっています。

(refs. https://github.com/microsoft/TypeScript/blob/v5.1.6/src/compiler/checker.ts 16423行目)

    function getUnionTypeWorker(types: readonly Type[], unionReduction: UnionReduction, aliasSymbol: Symbol | undefined, aliasTypeArguments: readonly Type[] | undefined, origin: Type | undefined): Type {
        let typeSet: Type[] | undefined = [];
        const includes = addTypesToUnion(typeSet, 0 as TypeFlags, types);
        // (中略)
        const objectFlags = (includes & TypeFlags.NotPrimitiveUnion ? 0 : ObjectFlags.PrimitiveUnion) |
            (includes & TypeFlags.Intersection ? ObjectFlags.ContainsIntersections : 0);
        return getUnionTypeFromSortedList(typeSet, objectFlags, aliasSymbol, aliasTypeArguments, origin);
    }

(refs. https://github.com/microsoft/TypeScript/blob/v5.1.6/src/compiler/checker.ts 16222行目)

    function addTypeToUnion(typeSet: Type[], includes: TypeFlags, type: Type) {
        const flags = type.flags;
        // We ignore 'never' types in unions
        if (!(flags & TypeFlags.Never)) {
            includes |= flags & TypeFlags.IncludesMask;
            if (flags & TypeFlags.Instantiable) includes |= TypeFlags.IncludesInstantiable;
            if (type === wildcardType) includes |= TypeFlags.IncludesWildcard;
            if (!strictNullChecks && flags & TypeFlags.Nullable) {
                if (!(getObjectFlags(type) & ObjectFlags.ContainsWideningType)) includes |= TypeFlags.IncludesNonWideningType;
            }
            else {
                const len = typeSet.length;
                const index = len && type.id > typeSet[len - 1].id ? ~len : binarySearch(typeSet, type, getTypeId, compareValues);
                if (index < 0) {
                    typeSet.splice(~index, 0, type);
                }
            }
        }
        return includes;
    }

    // Add the given types to the given type set. Order is preserved, duplicates are removed,
    // and nested types of the given kind are flattened into the set.
    function addTypesToUnion(typeSet: Type[], includes: TypeFlags, types: readonly Type[]): TypeFlags {
        let lastType: Type | undefined;
        for (const type of types) {
            // We skip the type if it is the same as the last type we processed. This simple test particularly
            // saves a lot of work for large lists of the same union type, such as when resolving `Record<A, B>[A]`,
            // where A and B are large union types.
            if (type !== lastType) {
                includes = type.flags & TypeFlags.Union ?
                    addTypesToUnion(typeSet, includes | (isNamedUnionType(type) ? TypeFlags.Union : 0), (type as UnionType).types) :
                    addTypeToUnion(typeSet, includes, type);
                lastType = type;
            }
        }
        return includes;

注目すべきは addTypeToUnion 関数の以下のコードで、

    const len = typeSet.length;
    const index = len && type.id > typeSet[len - 1].id ? ~len : binarySearch(typeSet, type, getTypeId, compareValues);
    if (index < 0) {
        typeSet.splice(~index, 0, type);
    }

型の配列 typeSet に型を挿入する際、配列の末尾ではなく、二分探索を用いて id の小さい順になるように要素を挿入していることがわかります。

getTypeId は文字通り型の id を返す関数、compareValues は要約すると「a と b を受け取って a - b を返す」関数であり、binarySearch 関数は引数が「array, value, keySelector, keyComparator[, offset]」となっている関数です。keySelectorgetTypeIdkeyComparatorcompareValues を与えているので、id の小さい順にチェックすることになります。

このことから、TypeScriptエンジンを通すと共用体型は「id の小さい順に型が並ぶ」ことになります。これがわかる例として、「TL;DR」のセクションにリンクした TS Playground の内容において、共用体型を使う前にその型で使われている型を使用すると、共用体型を持つ変数をマウスオーバーすると順番が入れ替わる、という現象が確認できます。

型の順番が変わる例

※ この id の順番は単一ファイルではなくプロジェクト全体でカウントされるため、import/export で複数のファイルが読み込まれている場合、その分カウントが増えている場合があります。これは、特に数値リテラルや文字列リテラルの型を含む共用体の順番に影響を及ぼす可能性があります。

型の順番が処理によっても変わる

前述の TS Playground では、「let x: 1 | 2 | 3 | 4 | 5;」による「x」の型は「3 | 5 | 1 | 2 | 4」となっていました。しかし、TypeScriptエンジンの使い方によってはさらに順番が変わることがあります。

ここで、独自にTSエンジンを使ってみる例を示します。以下は、前述の Playground のコードを「test.ts」として保存済みの場合に、そこで定義されている変数の型を出力する Node.js スクリプトです。

(ファイル: test.mjs)

import ts from 'typescript';

const program = ts.createProgram(['test.ts'], {});
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile('test.ts');
for (const statement of sourceFile.statements) {
    if (ts.isVariableStatement(statement)) {
        for (const varDecl of statement.declarationList.declarations) {
            const sym = checker.getSymbolAtLocation(varDecl.name);
            const type = checker.getTypeOfSymbol(sym);
            console.log(`variable: name = ${sym.name}, type = `, checker.typeToString(type));
        }
    }
}

この出力例は以下の通りです。

>node test.mjs
variable: name = pre, type =  3
variable: name = pre2, type =  5
variable: name = x, type =  3 | 5 | 1 | 2 | 4
variable: name = hoge, type =  A | B
variable: name = piyo, type =  A & B

注目点として、「hoge」の型が「B | A」ではなく「A | B」となっている点があります。これは、上記のスクリプトでは変数定義以外は無視しているため、hoge に到達するまで checker (TSエンジン) 内で型「A」および型「B」への参照が作られておらず、hoge ではじめてそれらへの参照ができるため、記述順に id が振られ、結果「A | B」という順番になっています。

さらに id の順序が変化する分かりやすい例として、test.mjs を以下のように変更してみます。

(ファイル: test.mjs)

import ts from 'typescript';

const program = ts.createProgram(['test.ts'], {});
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile('test.ts');
for (const statement of sourceFile.statements) {
    if (ts.isVariableStatement(statement)) {
        for (const varDecl of statement.declarationList.declarations) {
            // 「pre」という変数に関してはスキップしてみる
            if (varDecl.name.getText() === 'pre') {
                continue;
            }
            const sym = checker.getSymbolAtLocation(varDecl.name);
            const type = checker.getTypeOfSymbol(sym);
            console.log(`variable: name = ${sym.name}, type = `, checker.typeToString(type));
        }
    }
}

これを実行すると以下のようになります。

>node test.mjs
variable: name = pre2, type =  5
variable: name = x, type =  5 | 1 | 2 | 3 | 4
variable: name = hoge, type =  A | B
variable: name = piyo, type =  A & B

x 直前のタイミングで型「3」への参照が無くなったため、x の型が「5 | 1 | 2 | 3 | 4」に変わっています。

まとめ・その他

TypeScriptの共用体型の順序は、それを構成する型の出現順や、TSエンジンを使っている処理に依存します。型チェックにおいてはこの順番が変わることは何も問題がないのですが、Storybookなどでパラメーターに対して列挙型として共用体型の情報を使っているケースにおいては、見やすさの観点などから注意する必要があるかもしれません。

なお、共用体型の順番に関しては TypeScript の issue として Use a consistent ordering when writing union types · Issue #17944 が作られていますが、2023年8月4日時点で動きはありません。