🦺

TypeScriptの安全性の穴

2022/06/06に公開

TypeScriptの型システムは不完全です。
型システム上しょうが無い所も、どうしてそうなってるのって所もあります。
この記事では共通認識となっているようなものから、あまり認知されていないものも含め、TypeScriptの安全性の穴になり得る所を紹介します。

tsconfig.json

https://www.typescriptlang.org/tsconfig

項目が大量にありますが、この記事では型安全性に大きく影響のあるオプションのみ紹介します。

strict

このオプションが無効だと型安全性が大幅に失われます。
具体的にはnull | undefinedが無かったことにされたり、暗黙のanyが大量発生したりします。
なんでデフォルトがfalseなんですか?
詳しくはこの記事を読んでください。
https://qiita.com/kyntk/items/9c596306495aef06dbc0

noUncheckedIndexedAccess

このオプションがfalseの場合、インデックスシグネチャへのプロパティーアクセスが型安全ではなくなります。

const array: number[] = [4, 6, 8, 12, 20];
const element = array[1000]; // 存在しないインデックスへアクセス

// `noUncheckedIndexedAccess: false`(デフォルト)なら型は`T`
// `noUncheckedIndexedAccess: true`なら型は`T | undefined`
// 実際にはundefinedが入っている
element;

詳しくはこの記事を読んでください。
https://zenn.dev/lollipop_onl/articles/eoz-ts-no-unchecked-indexed-access

@ts-nocheck | @ts-ignore | @ts-expect-error

tscを黙らせます。全て人間の責任です。
どうやってもエラーが消えない時の最終手段ですが、そういう時は多分型が複雑すぎます。
どうしても使うならば、エラーが出ないのにコメントだけ残って欲しくないので@ts-expect-errorの方が良いでしょう。
@ts-nocheckは...うん...わざわざ拡張子.tsとか.tsxにしときながら使う人いないでしょ...。

https://typescript-eslint.io/rules/ban-ts-comment

良くない型

any

anyは一切の型チェックをパスします。また、anyは伝播します。
できる限りunknownか他の型を使いましょう。
anyを使わざるをえない場合も、型がanyである範囲は最小限となるようにすべきです。

https://typescript-eslint.io/rules/no-explicit-any

標準ライブラリでも適切な型付けがされておらず、anyが使われている場合があります。
これに対してはbetter-typescript-lib等で標準ライブラリを置き換えてしまう手があります。

any以外の良くない型

  • {}, Object
    これらの型は「non-nullである」という意味しかありません。
    そうでない意味を表したいのであればRecord<string, unknown>等の型を使いましょう。
    空のオブジェクトを表したい場合はRecord<string, never>を使用できます。
  • Function
    引数や返り値等の型の情報がありません。具体的な型を使用しましょう。
  • String, Number, etc... (プリミティブ型をオブジェクトにした型)
    そもそもnew Stringなんてしません。小文字の方を使ってください。

https://typescript-eslint.io/rules/ban-types

as

asは全く関係のない型にはキャストできませんが、危険なことに変わりありません。
ただ、型をコネコネしているとどうしても型が合わなくてasを使わざるを得ないこともあります。
それでもanyで全て誤魔化すよりは、明示的で危険な箇所が限定されるasを使ったほうが良いでしょう。

https://typescript-eslint.io/rules/consistent-type-assertions

! (non-null assertion operator)

背後からTypeErrorにCannot read properties of nullとか言われながら刺されたくないなら、大人しくnullチェックすべきです。
しかし、as等と違って影響する型がnull | undefinedだけとわかっています。
テスト等で安全性を確保できるのであれば、積極的に使っていくという選択肢もあるでしょう。

https://typescript-eslint.io/rules/no-non-null-assertion

typescript-eslintに他にもいくつかルールがあります。

x is type | asserts x is type

is/asserts-isは戻り値の型がboolean/voidであればどんな実装でもエラーを出しません。
実装には気をつける必要がありますが、正しく使えれば強力な武器になります。

// 間違った定義
const isString = (x: unknown): x is string => typeof x === "number";

const hoge: unknown = 0;
if(isString(hoge)) {
    // 型は`string`だが、実際には0が入っている
    hoge;
}

ちなみに、ジェネリクスと合わせてこんな事もできます。
asと同様の危険性を持っていますが、asserts版asのように使用できます。
多くの場合、他の方法を使った方が良いですが、稀に使い道があります。

const assertsAs: <T>(x: unknown) => asserts x is T = () => { /* noop */ };

const hoge: unknown = 0;
if(Number.isInteger(hoge)) {
    assertsAs<number>(hoge);
    hoge // : number
}

readonlyの消失

配列はreadonly T[]push,pop等のメソッドが生えてないためT[]に代入できません。
一方でオブジェクトのreadonlyは無視できてしまいます。

const hoge: { readonly prop: number } = { prop: 1 };
const fuga: { prop: number } = hoge; // <- ???
fuga.prop = 1000;

https://github.com/microsoft/TypeScript/issues/13347

危険なキャスト[1]

TypeScriptのオブジェクトや関数の返り値は共変です。
つまり、より弱い型に代入できます。
これは値を取得する場合は問題ありませんが、値を代入する時が問題です。
参照を別の変数に入れ直すことはあまりなさそうですが、関数の引数とかで起こるともう追いきれません。
このあたりもイミュータブルが重宝される理由でしょう。

const x: { hoge: number } = { hoge: 0 };

// `number`を`number | string`に代入(より弱い型に代入)
// readonlyが付くなら良いのだが...
const y: { hoge: number | string } = x;

// 拡張した`string`型の値を代入
y.hoge = "string";

// 型は`number`だが、実際には"string"が入っている
x.hoge;

メソッドの引数はstrictFunctionTypesがtrueでも双変です。
つまり、引数がより強い型にも、より弱い型にも代入できます。
配列等ジェネリクスを引数で使った型の共変性を保つためこうなっている様です。

const x: number[] /* { push(x: number): number; ... } */ = [];

// 引数がより弱い型に代入
const y: (number | string)[] /* { push(x: number | string): number; ... } */ = x;

// 拡張した`string`型の値で呼び出し
y.push("string");

// 型は`number[]`だが、実際には["string"]が入っている
x;

// 引数がより強い型への代入も可能
const z: { push(x: 0): number }  = x;

Optional | 暗黙的に存在する可能性があるプロパティー

イミュータブルでも、Optionalなプロパティーを使うと型を偽装できます。

// yの型が`{}`ですが、ここでは影響はありません。
// プロパティーを追加してもエラーは出ません。

const x: { hoge: string } = { hoge: "string" };

// より弱い型に代入
const y: {} = x;

// Optionalなプロパティーとして復活
const z: { hoge?: number } = y;

// Optionalを剥がす
const hoge = z.hoge ?? 0;

// スプレッド構文だと工程を短縮できる
const w: { hoge: number } = { hoge: 0, ...y };

TypeScriptの標準ライブラリのObject.keysの型が、keyofを使っていないのもこれが理由にあるでしょう。

interface ObjectConstructor {
    keys<T>(o: T): (keyof T)[];

    // TypeScriptの標準ライブラリの型
    // keys(o: object): string[];
}

const keys = Object.keys<{ hoge: string }>({ hoge: "string", fuga: 0 });

// 型は`"hoge"[]`だが、実際には["hoge", "fuga"]が入っている
keys;

関数の引数は反変です(strictFunctionTypesがtrueの時)
つまり、引数がより強い型に代入できます。
これは基本的に問題ないはずなのですが、こちらもOptionalな引数を使うと全く関係の無い型へ変換できてしまいます。

// 省略可能な引数(デフォルトでOptionalを剥がす)
const f = (n: number = 0) => {
    // 型は`number`だが、`h("string")`として呼ばれた時、実際には"string"が入っている
    n;
};

// Optionalな部分の型情報を落とす
const g: () => void = f;

// 引数がより強い型に代入
const h: (x: string) => void = g;

h("string");

static property

クラスのstaticプロパティーは初期化しなくてもエラーが出ません(なんで?)
strictNullChecksやstrictPropertyInitializationがtrueでもです(なんで?)

class Hoge {
    static x: string;
}

https://github.com/Microsoft/TypeScript/issues/27899

参考

というか読むと良い記事の紹介。

https://zenn.dev/f_subal/articles/what-is-bivariance-hack
https://qiita.com/uhyo/items/a354d4135e3dec15d01e
https://qiita.com/uhyo/items/aae57ba0734e36ee846a
https://susisu.hatenablog.com/entry/2021/11/27/225004

脚注
  1. これをキャストというのは適切でない気がしますが、いい呼び方が思いつかなかったためキャストと呼んでおきます。 ↩︎

GitHubで編集を提案

Discussion