Vinyl

as(型アサーション)に逃げない

はじめに

TypeScriptを使っている現場だとanyは良くないという認識は一般的かと思います。

ただしそのanyを回避する為にas(型アサーション)を使うのはベストではないということはそこまで広く認識されてないと感じます。(APIの返り値にasを使っているのをよく見ます。)

今回は型アサーションの危険性と、それに対する回避方法を備忘録も兼ねてまとめます。

型アサーションの危険性

以下のような型定義をしてをそれを元に変数に代入し、その結果を確認していきます。

type User = { name: string };
const user01: User = { name: 'Taro' }; // No Error
const user02 = { name: 'Taro' } as User; // No Error
const user03: User = { name: 'Taro', age: 11 }; // Error:  オブジェクト リテラルは既知のプロパティのみ指定できます。'age' は型 'User' に存在しません
const user04 = { name: 'Taro', age: 11 } as User; // No Error

TypeScriptでは、型アサーションを使用することで、開発者はコンパイラに対して「この値は指定した型である」と伝えることができます。型アサーションは、TypeScriptの型システムを一時的にバイパスする手段であり、型安全性を保つ責任が開発者に委ねられます。

const user02 = { name: 'Taro' } as User;

この行では、オブジェクト { name: 'Taro' } に対してas User という型アサーションを行っています。これにより、開発者はこのオブジェクトを User 型として扱うことをコンパイラに伝えています。そしてコンパイラはこの情報を信じ、型チェックをスキップします。

一方、user03の場合

const user03: User = { name: 'Taro', age: 11 };

ここでは、明示的に変数 user03 の型を User として宣言しています。しかし、オブジェクト { name: 'Taro', age: 11 } には User 型に存在しないプロパティage が含まれています。このため、TypeScriptコンパイラは型チェック時にエラーを検出し、開発者に警告します。

ここでの重要な違いは、型アサーションを行った場合、コンパイラはそのアサーションを信じ、型チェックをスキップしますが、型注釈を使用した場合、コンパイラは厳密な型チェックを実行します。型アサーションを誤って行うと、実行時に予期しないエラーやバグの原因になる可能性があるため注意が必要です。

型ガードを使おう

型ガード(Type Guard)は、TypeScriptにおいて、ある値が特定の型であることを確認し、その型情報をコンパイラに伝える仕組みです。

これによりコード内で型の安全性を高めることができます。

型アサーションを使うよくない例と共に見ていきます。

type User = {
    name: string;
    age: number;
};
// 型アサーションでUser型に強制的に変換
const user = { name: 'Taro' } as User;
console.log(user.age); // undefined

このコードでは、型アサーションで文字列をUser型に強制的に変換していますが、実際にはageのプロパティを持っていないのでアクセスできません。

このように、型アサーションはコンパイラの型チェックを無視することになるため、実行時にエラーが発生する可能性があります。

それを解消するためにTypeGuardを使う例は、以下のようなコードです。

function isUser(obj: unknown): obj is User {
    const user = obj as User;
    return typeof user === 'object' && 'name' in user && 'age' in user;
}

このコードでは、ユーザー定義の型ガード関数isUserを使って、引数objがUser型であるかどうかをチェックしています。

このように、型ガードは実行時に型を確認することで、型の安全性を保証することができます。

アサーション関数と組み合わせる

アサーション関数とは、ユーザー定義型ガード関数として使われる一種の関数です。

x is TのようなType predicateは異なり、関数が例外を投げるかどうかで型を判定します。通常Type predicateは、boolean型の戻り値に対して使われますが、アサーション関数はvoid型の戻り値に対して使われます。

以下はアサーション関数の例です。

function assertString(v: unknown): asserts value is string {
    if (typeof v !== 'string') {
        throw new Error('value is not a string');
    }
}
 
const value: unknown = 'hello';
assertString(value);
console.log(value.toUpperCase());

またType predicateと組み合わせるとよりスッキリかけます。

function isString(v: unknown): v is string {
    return typeof v === 'string';
}
 
function assertString(v: unknown): asserts v is string {
    if (!isString(v)) {
        throw new Error('value is not a string');
    }
}

例えば、先ほどのユーザー定義型ガード関数のisUserは内部でunknownによるnullの可能性を消す為にUser型をアサーションしていますが、以下のような便利な関数を間に挟むことでasを削除することもできます。

function isExist<T>(v: T | null | undefined): v is NonNullable<T> {
    return typeof v !== 'undefined' && v !== null;
}
 
function assertIsExist<T>(v: T | null | undefined, target = ''): asserts v is NonNullable<T> {
    if (!isExist(v)) {
        throw new Error(`${target} should be specified`);
    }
}
 
function isUser(obj: unknown): obj is User {
    assertIsExist(obj);
    return typeof obj === 'object' && 'name' in obj && 'age' in obj;
}

さいごに

ユーザー定義型ガードに関しては今だとzod等のライブラリを用いて実装することが多いかとおもいますが、ライブラリに頼らずnullチェックだけで使いたい等、ちょっとしたケースでの活用に関しては今だに非常に便利だと思います。