Programming Field

[TypeScript] オブジェクトの型をより厳密に判定する

外部入力から得たデータなど、データを使う前に型チェックをしてその上でチェックした型として扱いたいことがあります。数値や文字列の場合は typeof 演算子を使えば簡単にチェックできますが、オブジェクトをチェックして特定のオブジェクト型として扱いたい場合には、(TS 4.9 時点では)ひと工夫必要です。

※ TS 4.9 で入った機能を一部用いています。それ以前のバージョンではワークアラウンドが必要になる場合があります。

オブジェクトのフィールド(プロパティ)のチェック

データがオブジェクトかどうかは、

function a(o: unknown) {
    if (typeof o === 'object' && o != null) {
        // o はオブジェクト(object)
    }
}

のようにすれば「オブジェクト」(object型)という判定をすることはできます。

※ 「typeof null === 'object'」であるので、「typeof o === 'object'」の段階では o は「object | null」という型になります。したがって、「typeof o === 'object'」に加えて「o != null」の判定が必要になります。

さらに、TS 4.9 以降では in 演算子によって型を狭める機能が追加されているため、

function a(o: unknown) {
    if (
        typeof o === 'object' && o != null &&
        'hoge' in o
    ) {
        // o は object & Record<'hoge', unknown> となり、
        // 「o.hoge」というアクセスができるようになる
        if (typeof o.hoge === 'number') {
            // o.hoge は number
        }
    }
}

とすることでオブジェクトをフィールド(プロパティ)込みでより厳密に判定することができます。

しかしながら、フィールドの型チェックを行っても、TS 4.9 では(おそらく 5.0 でも)オブジェクト自体の型を狭める処理は働かず、上記の例では「typeof o.hoge === 'number'」のガード下でも引き続き o の型は「object & Record<'hoge', unknown>」のままになります。そのため、

interface Hoge {
    hoge: number;
}

declare function piyo(o: Hoge): void;

function a(o: unknown) {
    if (
        typeof o === 'object' && o != null &&
        'hoge' in o && typeof o.hoge === 'number'
    ) {
        // o.hoge は number だが…
        piyo(o); // エラー
    }
}

と書くと、「piyo(o)」のところで型エラーとなってしまいます。

※ 既に Suggestion は出ているため、これの動向次第では上記コードが問題なくなる可能性はあります。参考: Type guard should infer the type of parent object when applied on a property · Issue #42384 · microsoft/TypeScript

ユーザー定義型ガードの利用

そこで、「ユーザー定義型ガード」の関数を作成して用いることで、型を狭めてデータを扱うことができるようになります。例えば以下のように書くことができます。

interface Hoge {
    hoge: number;
}

declare function piyo(o: Hoge): void;

function isHoge(o: unknown): o is Hoge {
    // o は Hoge
    return typeof o === 'object' && o != null &&
        'hoge' in o && typeof o.hoge === 'number';
}

function a(o: unknown) {
    if (isHoge(o)) {
        // o は Hoge
        piyo(o); // 問題なし
    }
}

これは一見すると良さそうに見えますが、

interface Hoge {
    hoge: number;
}

declare function piyo(o: Hoge): void;

function isHoge(o: unknown): o is Hoge {
    return typeof o === 'object' && o != null &&
        'hoge' in o && typeof o.hoge === 'string'; // 間違えて「string」で判定
}

function a(o: unknown) {
    if (isHoge(o)) {
        // o は Hoge になる
        piyo(o); // 問題なし…?
    }
}

とユーザー定義型ガードで用いる関数内の判定式に誤りがあっても、型チェック機能では検出できません。

satisfies による「念のためチェック」

ところで、satisfies 演算子を使うと、任意のデータが指定の型を満たしているかどうかをチェックすることができ、例えば

function b(x: unknown) {
    if (typeof x === 'number') {
        // x が number であることを念のためチェック
        x satisfies number;
    }
}

と書くことができます。これを用いれば

interface Hoge {
    hoge: number;
}

function isHoge(o: unknown): o is Hoge {
    if (
        typeof o !== 'object' || o == null ||
        !('hoge' in o) || typeof o.hoge !== 'number'
    ) {
        return false;
    }
    o satisfies Hoge; // 残念ながらエラー
    return true;
}

と「念のためチェック」をすることができるように見えます。が、「オブジェクト自体の型を狭める処理が働かない」ためにこれでは意図した挙動は得られません。

フィールドの型をチェックする関数を書く

これを解決するために、「フィールドの型」を判定するユーザー定義型ガードを作ってしまうという手があります。具体例は以下の通りです。

// 「typeof 演算子」の型を取得するためのダミー関数
function _dummy() { return typeof ''; }
type TypeofType = ReturnType<typeof _dummy>;
// 「TypeofType」は TS 4.9 時点では「'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function'」になる

// typeof 演算子の結果の文字列に対応する型を書いたマップオブジェクト
interface TypeofTypeMap {
    number: number;
    string: string;
    boolean: boolean;
    bigint: bigint;
    object: object | null;
    function: Function;
    symbol: symbol;
    undefined: undefined;
}

// o オブジェクトにあるフィールド name の typeof の結果が typeName になるかをチェックする関数
// ※ もし TypeofType に更新があった場合、「TypeofTypes[TypeofName]」が型エラーになるので
//    「TypeofTypes」のマップを更新する必要がある
function isEqualFieldType<
    T extends object,
    K extends keyof T,
    TypeofName extends TypeofType
>(o: T, name: K, typeName: TypeofName): o is T & Record<K, TypeofTypeMap[TypeofName]> {
    return typeof o[name] === typeName;
}

isEqualFieldType の第2引数「name」は最低限「keyof T」を満たす名前である必要があるため、事前に「name in o」のチェックを行って o の型を狭めておく必要があります。(「name in o」を isEqualFieldType 内に移動させる場合はどうしても「as」が必要になってしまいます。)

この 「isEqualFieldType」関数を用いると、

interface Hoge {
    hoge: number;
}

function isHoge(o: unknown): o is Hoge {
    if (
        typeof o !== 'object' || o == null ||
        !('hoge' in o) || !isEqualFieldType(o, 'hoge', 'number')
    ) {
        return false;
    }
    o satisfies Hoge; // 問題なくなる
    return true;
}

と書くことができ、ユーザー定義型ガードの厳密性を高めることができます。

この「isEqualFieldType」を使えばあらゆるオブジェクトの型を厳密にすることができそうですが、残念ながらフィールドがオブジェクト型の場合「object | null」以上のチェックを行うことができません。例えば

interface Foo {
    foo: string;
}

interface Hoge {
    hoge: number;
    // フィールドがオブジェクト型の場合
    piyo: Foo;
}

function isHoge(o: unknown): o is Hoge {
    if (
        typeof o !== 'object' || o == null ||
        !('hoge' in o) || !isEqualFieldType(o, 'hoge', 'number') ||
        // 「o.piyo」が存在して「Foo」かどうかをチェック
        !('piyo' in o) || !isEqualFieldType(o, 'piyo', 'object') ||
        o.piyo == null ||
        !('foo' in o.piyo) || !isEqualFieldType(o.piyo, 'foo', 'string')
    ) {
        return false;
    }
    o.piyo satisfies Foo; // 問題なし
    o satisfies Hoge; // エラーになる
    return true;
}

のように、「o.piyo」がオブジェクトの場合は最後の「o satisfies Hoge」による「念のためチェック」はこのままではできないことになります。

フィールドの型がオブジェクトかをチェックする関数を書く

そこで、さらに「isEqualFieldTypeObject」という関数を以下のように定義します。

// o オブジェクトにあるフィールド name が predicate を満たす場合、そのフィールドを O 型とみなす関数
function isEqualFieldTypeObject<
    T extends object,
    K extends keyof T,
    O extends object
>(o: T, name: K, predicate: (x: unknown) => x is O): o is T & Record<K, O> {
    return predicate(o[name]);
}

これを使って以下のように記述すると、子オブジェクトの型もチェックすることができるようになります。

interface Foo {
    foo: string;
}

interface Hoge {
    hoge: number;
    piyo: Foo;
}

// 子オブジェクトのチェックを関数に分離
function isFoo(o: unknown): o is Foo {
    if (
        typeof o !== 'object' || o == null ||
        !('foo' in o) || !isEqualFieldType(o, 'foo', 'string')
    ) {
        return false;
    }
    o satisfies Foo;
    return true;
}

function isHoge(o: unknown): o is Hoge {
    if (
        typeof o !== 'object' || o == null ||
        !('hoge' in o) || !isEqualFieldType(o, 'hoge', 'number') ||
        // 「o.piyo」が存在して「Foo」かどうかをチェック
        !('piyo' in o) || !isEqualFieldTypeObject(o, 'piyo', isFoo)
    ) {
        return false;
    }
    o.piyo satisfies Foo; // 問題なし
    o satisfies Hoge; // 問題なし
    return true;
}

サンプルコード (全コード)

※ 以下に gist.github.com にアップロードしたソースコードを埋め込んでいます。表示されない場合はこちら → https://gist.github.com/jet2jet/2f2e0d3a62150aaca541459527ce0bd4

更新履歴

  • 2023/01/21 - 作成