TypeScriptの型アサーションいつ使う?


TypeScriptを書いていると型アサーションasを使う場面は出てくると思う。 このasは使い所を間違えると危険な場合があります。 じゃあ危険だから使わない方が良いのかと言われればそうではないと思います。

型アサーションとは

サバイバルTypeScriptを引用すると、

型推論を上書きする機能

と書いてあります。

const value: unknown = "hello";

const str = value as string;
console.log(str.length);

strという値はunknownという型のvalueが代入されていますが、これをstring型として扱うといった意味合いになると私自身は解釈しています。

型アサーションを使う場面ではない

1. formでの送信後の処理

interface ProfileForm {
  name: string;
  age: number;
  subscribed: boolean;
}

export function Profile() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const formData = new FormData(e.currentTarget);

    const values = {
      name: formData.get("name"),
      age: formData.get("age"),
      subscribed: formData.get("subscribed"),
    } as ProfileForm;

    console.log(values.age + 1);
    if (values.subscribed) {
      console.log("購読中");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <input name="age" type="number" />
      <input name="subscribed" type="checkbox" />
      <button type="submit">送信</button>
    </form>
  );
}

このコードの問題点は以下です。

  • formData.get()の戻り値はFormDataEntryValue | nullであり、最初からstringnumberbooleanではありません
  • as ProfileFormは値を変換しているわけではなく、TypeScriptに対して「これはProfileFormです」と言っているだけです
  • age<input type="number" />でもsubmit後はnumberではなく文字列です
  • subscribedbooleanではなく、チェックされていれば"on"、未チェックならnullです
  • nameageが未入力ならnullになる可能性があります
  • values.age + 1は数値計算のつもりでも、実際には文字列結合になってしまう可能性があります
  • if (values.subscribed)booleanを扱っているわけではなく、文字列のtruthy判定に依存しています
  • 本来であれば型エラーで気づけるはずの危険を、asが握りつぶしてしまいます

つまり、外部から受け取った値を何も検証していないのに、安全な型として扱ってしまっているのが問題です。 特にsubmit直後はI/Oの境界なので、asで押し切るのはかなり危険です。

考え方としてはformData.get()で受け取った値をそのまま信用するのはよろしくありません。 なので必ず検証することを推奨します。

ライブラリを使わない場合には

  • typeofstringかどうかを確認する
  • 数値項目はNumber()などで明示的に変換する
  • Number.isNaN()で不正な値ではないかを確認する

などです。

if (typeof value === "string") {
  throw Error("type error");
}

function isString(value: unknown): value is string {
  return typeof value === "string";
}

といった形で表すことができます。

ライブラリを使うとすればzodvalibotを使うと良いかと思います。

Intro | Zod Introduction to Zod - TypeScript-first schema validation library with static type inference zod.dev

Valibot: The modular and type safe schema library Validate unknown data with Valibot, the open source schema library with bundle size, type safety and developer experience in mind. valibot.dev

ちなみに、私は最近はバンドルサイズやツリーシェイキングの面からvalibotを使うようにしています。

2. APIからデータを取得する処理

const res = await fetch("http://onakahetta.com");

const data = res.json() as Food;

このコードも同じように危険です。

  • json()で返ってくる値は外部から来た不確実なデータです
  • as Foodを付けても、レスポンスの中身がFoodの構造になるわけではありません
  • APIの仕様変更や不正なレスポンスがあっても、TypeScript上ではFoodとして扱えてしまいます
  • 必須プロパティが欠けていてもコンパイル時には気づけず、実行時エラーの原因になります
  • これもI/O境界でasを使って型チェックをすり抜けている例です

APIレスポンスもformと同様に、そのまま信用するべきではありません。

  • await res.json()で未知のデータとして受け取る
  • その後にプロパティの有無や型を確認する
  • 必要であれば型ガードやバリデーションライブラリを使う

フォーム処理と同様に型チェックやバリデーションチェックはしっかりと行うことが大事です。

外から入ってくる値に対してasを使うと、安心感だけが生まれて実際の安全性は上がりません。 I/O境界では、型を主張する前に値を検証することが大切です。

では、asは使うべきではないのでしょうか?

使うべきケース

asは使うべきではないわけではなく使うべきケースもあります。 それは明らかに誰がみてもその型だと確信できる時です。

例1

function getValue(hoge: string | number): string | number {
  if (typeof hoge === "string") {
    return hoge;
  }

  return hoge + 1;
}

const value = getValue("hoge") as string;

const valueLength = value.length;

あまりこのようなことはやらないかもしれないですが、この場合、asを使っても問題ないです。

逆にasを使わず、

const value = getValue("hoge");

if (typeof value === "string") {
  throw new Error("type error");
}

const valueLength = value.length;

とした場合、型ガードをする部分は無駄な処理となってしまいます。

getValue関数は引数がstringの場合はstringとして値が返ってきてnumberの場合はnumberが返ってくるということがわかっています。 つまり、この時点で開発者が値の振る舞いを把握できており、外部から不確実な値が入ってくるケースとは性質が違います。

不安な場合は、テストコードを書くと良いと思います。

例2

/**
 * props定義の時に使う
 */
type CheckerProps<T, TExpect, TError extends string> = T &
  (Exclude<keyof T, keyof TExpect> extends never ? object : TError);

interface Props {
  as?: Extract<ElementType, "div" | "section" | "article" | "main" | "p">;
  className?: string;
  style?: Omit<CSSProperties, "center">;
  children: ReactNode;
}

export function FontCenter<T extends Props>(
  props: CheckerProps<T, Props, "fontCenter has not any props.">,
) {
  const { as = "p", className, ...rest } = props as Props;

  const Component = as;

  const cn = classMerger(["text-center", className ?? ""]);

  const asProps = {
    className: cn,
    ...rest,
  };

  return <Component {...asProps} />;
}

ここでもasは使われていますが、これは危険ではないシチュエーションです。

CheckerPropsでは余計なスキームがあった場合にはstring型としてエラーにするといった型になります。

TypeScriptにおけるコンポーネント間の引数型厳密比較について、解決策を調査する zenn.dev

こちらの記事を参考にしました。

TypeScriptのコンパイラはstringPropsかのどちらかの可能性があるということで...restが使えずエラーになるといった認識です。 ですがここで型チェックのロジック書いてしまうと無駄な処理となってしまいます。

まとめ

型アサーションは無闇に使ってはいけないですし、だからといって使ってはいけないというわけではないと思っています。 危険な部分は型チェックのロジックを入れて、型チェックの意味を成しておらず無駄が発生している場合にはasで賄うというのが良いやり方だと思っています。

サバイバルTypeScriptには、

型に関することはできるだけ、コンパイラーの型推論に頼ったほうが安全なので、型アサーションは、やむを得ない場合にのみ使うべきです。

とかいてあります。 asを使う場合に責任が伴うのでどの場面で使うべきかはチームで定めておく必要があるでしょう。