🙅‍♂️

TypeScriptで余剰プロパティを禁止してみた

2024/06/12に公開

一般的なtypescript開発において、型の柔軟性は過剰なんじゃないか、余剰プロパティは一括で禁止していいんじゃないかと感じたので、本当に禁止してみました。結構困りました。

余剰プロパティチェックとは

余剰プロパティチェック(excess property checking)とは、オブジェクトリテラルに対して行われる型チェックのことです。型定義に存在しないプロパティ(余剰プロパティ)がオブジェクトに存在すると、エラーが発生します。

type User = { name: string };
const user: User = { name: "taro", age: 20 }; // エラー

User型に存在しないageプロパティを代入しようとしているので、型エラーが発生します。これにより、誤って余計な情報を持ったオブジェクトを代入することを防ぐことができます。ありがたい仕組みです。

ちなみにこの仕組み、オライリーの本などでは過剰プロパティチェックと訳されていますが、本記事では余剰プロパティ / 余剰プロパティチェックと呼びます。ご了承ください。

余剰プロパティチェックは変数に対して働かない

似たような例ですが、次のコードでは話が変わります。

type User = { name: string };
const taro = { name: "taro", age: 20 };
const user: User = taro; // エラーにならない

こちらはエラーになりません。というのも、余剰プロパティチェックはオブジェクトリテラルに対してしか働かないためです。string型のnameさえ持っていれば、あとはtaroがいくら余剰な情報を語ろうと、typescriptはその全てを許容します。

はじめてこれに遭遇したときは感覚的に納得できなかったことを覚えています。全然型安全じゃないじゃんって思いましたし、わざわざ締め付けを緩くする理由にピンときませんでした。

このあたりの記述をサバイバルTypeScriptから引用します

変数代入にも余剰プロパティチェックが働いたほうが良さそうと思われるかもしれません。型が厳密になるからです。しかし、そうなっていないのは、TypeScriptが型の安全性よりも利便性を優先しているためです。

https://typescriptbook.jp/reference/values-types-variables/object/excess-property-checking

有名な紙媒体では、ブルーベリー本のpp98-99にも関連記述があります。お手元でご確認ください。

https://gihyo.jp/book/2022/978-4-297-12747-3

なお、この辺の順序ですが、typescriptは構造的部分型を採用している → 途中から例外としてオブジェクトリテラルを代入する際は余剰プロパティチェックが働くようになった、という流れのようです。余剰プロパティチェックはtypescript1.6から導入された機能であり、それまでは余剰プロパティ付きのリテラルの代入も許容されていました。

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-1-6.html#stricter-object-literal-assignment-checks

余剰プロパティ許容の問題点

余剰プロパティのある変数が代入可能なのは、typescriptが型の厳格性よりも実用性を取っているからでした。

一方、型の柔軟性を担保することで発生しうる課題も、もちろん存在するはずです。実際の開発で遭遇しやすいパターンについて、いくつか例を挙げてみます。

型が化ける

たとえば、Object.keysにasで型をつけるような書き方はよく見かけますが、ここにうっかり余剰プロパティ入りの変数を代入しようものなら、keysには想定外のkeyが含まれてしまいます。

type User = { name: string };
const taro = { name: "taro", age: 20 };
const user: User = taro;
const keys = Object.keys(user) as (keyof User)[];
console.log(keys); // ["name", "age"]

この場合、keys の中には、本来 User にはないはずの age が含まれてしまいます。もしuserにtaroを代入する際、オブジェクトに対して余剰プロパティチェックを行っていれば、この現象は避けられたでしょう。

情報の流出

サーバーサイドでレスポンスを作るような例を見てみます。

type User = { id: string; name: string };
type ResponseUsersData = {
  total: number;
  data: User[];
};
const users = [
  { id: "id_1", name: "taro", email: "tarotaro@taro.taro" },
  { id: "id_2", name: "jiro", email: "jirojiro@jiro.jiro" },
];
const createResponse = (): ResponseUsersData => ({
  total: users.length,
  data: users,
});

こちらもエラーが出ません。Users情報として、本当はidとnameだけを返したかったのにも関わらず、メールアドレスが流出しました。

期待値の整合性

テスト環境やstorybookなどでモックを使う場合、余剰プロパティが許容されるせいで、期待値と実際の値が合わないことがあります。

type User = { name: string };
const mockUsers: User[] = [...Array(10)].map((_, i) => ({
  name: `taro${i}`,
  age: i,
})); // エラーにならない

こちらもエラーが出ません。User型にはageプロパティが存在しないのに、余剰プロパティが許容されてしまいました。うっかり jestなどでexpectの引数にこのmockUsersを渡してしまうと、テスト対象側になんの過失もなかったとしても、おそらくテストは落ちてしまうでしょう。

余剰プロパティを禁止してみる

余剰プロパティ許容によって発生する問題をいくつか挙げてみました。これだけを見ると、オブジェクトリテラル以外にも余剰プロパティチェックをかけたほうがいいように思えてきます。しかし、それをしてしまうと、typescriptの柔軟性が失われてしまう、という話でした。

複雑なものをつくる場合、型情報の利便性に助けられる機会はいくらでもあると思います。けれど実際のところ、一般的なアプリケーション開発を行う場合に限っては、本当にそこまでの柔軟性は必要でしょうか?フレームワークにガッツリ乗っかって開発する場合、余剰プロパティを許容する旨みはあまりないのではないでしょうか。というのが今回の主題です。

ということでここからは、余剰プロパティつきオブジェクトの代入を本当に禁止してみて、どれくらい困るかを体感していきます。とはいっても、tsconfigにはその手のオプションはないので、型レベルなりlinterなりで頑張る必要があります。

型レベルで制限をかける

こちらはzenn内に先駆者さんがいたので紹介。

https://zenn.dev/qsf/articles/6e346f7fd3aaf1

型のkeyと代入するオブジェクトのkeyを比較し、余剰プロパティがないかを確認します。

type StrictPropertyCheck<T, TExpected> =
  Exclude<keyof T, keyof TExpected> extends never ? T : never;

レスポンス等、余剰プロパティが発生してはいけないような場面で使うことで、開発を安全に進めることができるでしょう。構造が複雑なオブジェクトを扱う場合はまた一手間かかりそうですが、導入コストの低さを考えるとï現実的に使いやすい方法だと思われます。

例えば、以下の関数呼び出しは型エラーになります。安心。

type Animal = { name: string };
type StrictPropertyCheck<T, TExpected> =
  Exclude<keyof T, keyof TExpected> extends never ? T : never;

function serializeBasicAnimalData<T extends Animal>(
  animal: StrictPropertyCheck<T, Animal>,
) {}
const animal = { name: "dog", age: 10 };
serializeBasicAnimalData(animal); // Error

ただし今回の目的は余剰プロパティを全面禁止して困ってみることなので、次の linter のほうで検証することにしました。

eslintで制限をかける

残念ながら、linterの情報は全然ないです。5/19現在、Twitterで 余剰プロパティ eslint で検索しても一件もヒットしないくらいです。excess property eslint でもほぼ出てきません(なんなら、「余剰プロパティ」だけで調べても数えるほどでした)

また、npmリポジトリの方も調べましたが、探した限りではその手のルールは見つかりませんでした。excess property で検索しても1件もヒットしません。どなたか既存ルールをご存知の方いらっしゃいましたら教えていただけると助かります。

やむを得ず、今回は自前でルールを作りました。やることはシンプルで、型のプロパティと代入するオブジェクトのプロパティを一つ一つ確認し、変なプロパティが入っていないかを検証します。

作ったルールはnpmで公開しています。記事末で紹介しているので、よければ使ってやってください。ルールを適用すると、以下のように余剰プロパティを検知してくれます。

tsで余剰プロパティ禁止

JSXの場合はこんな感じの検知になります

jsxで余剰プロパティ禁止

余剰プロパティを禁止するデメリット

いくつかのプロジェクトで余剰プロパティを禁止してみて、実際に困った箇所を紹介します

対象プロジェクトは、世の中に公開されているいくつかのサンプルリポジトリや、自分のローカル環境にあったいくつかのリポジトリです。あまり対象数を稼げなかったので、たぶん偏り等あるかと思います。そういうものとしてみていただければです。

classとの相性が悪い

typescriptでは、クラスを定義するとクラス名と同じ名前の型が同時に定義されます。これについて余剰プロパティチェックをかけると、クラスで定義されたプロパティ以外のプロパティが入っているとエラーになります。

というわけである意味当然とも言えそうですが、余剰プロパティの禁止とclassとの相性は極めて悪いです。スーパークラスを型として使い、サブクラスのインスタンスを代入しようとすると、それだけで余剰プロパティエラーが発生します。

class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
class ExtendedUser extends User {
  age: number;
  constructor(name: string, age: number) {
    super(name);
    this.age = age;
  }
}
const user: User = new ExtendedUser("taro", 20); // 余剰プロパティエラー

あまりにも相性が悪かったので、先のeslintのプラグインのデフォルト設定では、代入された値がclassであれば余剰プロパティチェックをスキップするようにしています。

typeやinterfaceとの相性

classが全然ダメなら、typeやinterfaceの拡張でもたくさん引っかかりそう……かと思いきや、自前で定義したtypeやinterfaceについては、余剰プロパティを禁止して困る箇所はほとんどありませんでした。尤も、ここについてはプロジェクトのコーディング規約次第なところが大きいので、そういうこともあるよね、くらいで捉えていただきたいです。

その他、自分の環境では、ElementにHTMLElementを入れようとしてエラーになったり、BlobにFileを入れようとしてエラーになったりと、自前定義以外の箇所の怒られが目立ちました。以下はBlobとFileの例です。

const readData = (file: File) => {
  const reader = new FileReader();
  reader.readAsDataURL(file); // 余剰プロパティエラー
  reader.readAsDataURL(file as Blob); // OK
};

readAsDataURLにFileを渡す書き方はごく自然なようですが、よくよく考えると余剰プロパティのある引数でした。普段typescripを使っていても、こういったところは全然意識していないものです。実際に柔軟性を剥奪されてみて、はじめて気づくことが多かったです。

ちなみにFileの構造は以下の通りです。typescript/lib/lib.dom.d.ts に定義されています。

interface File extends Blob {
  readonly lastModified: number;
  readonly name: string;
  readonly webkitRelativePath: string;
}

ライブラリとの相性

ここまでclassやtype、interfaceについて触れてきましたが、これは当然ライブラリの使用箇所も同じです。そして当たり前ですが、たいていのライブラリは余剰プロパティを許容する前提で組まれています。そんな中で余剰プロパティを一括で禁止してしまうと、相性次第ではライブラリ使用箇所が片っ端からエラーになります。

手元のプロジェクトでは、mswやhttp、testing-libraryのuser-eventなどでエラーが発生しました。幸いこれらのライブラリは、余剰プロパティエラーが出る箇所が限られていたので対応のしようがありましたが、世の中は広いのでそう簡単にいかないものも多いでしょう。

所感

最初から結論が出ていた話ではありますが、大抵のプロジェクトにおいて、余剰プロパティの禁止は無理筋かと思われます。一般的な開発においても、私たちは知らず知らずのうちに柔軟性に助けられているようです。

仮にプロジェクトのコーディング規約との相性が良かったとしても、使っているライブラリと噛み合わないことも多々あるでしょう。typescriptの柔軟性を前提として作られた資産に、あとから禁止を強要したとして、それがうまくいくケースは多くないはずです。

一方で、意外と何とかなるな、とも思いました。classに関してはお手上げですが、typeやinterfaceについては、自前で定義したものについては、余剰プロパティを禁止してもそこまで困らないことが多かったです。ライブラリに関しては、今回がたまたま相性が良かっただけかもしれませんが、やりようはありそうです。何らかの条件をつけた上で、余剰プロパティを緩く禁止するようなやり方は、意外と選択肢になるかもしれません。

個人的には、余剰プロパティを許容することによって発生する課題を結構重くみています。そのため、多少のコストを払ってでも、余剰プロパティを禁止する価値はあると考えます。禁止とまではいかなくても、PRレビューで確認を徹底する、主要な関数に型レベルの制限をかけるなど、何らかの決まり事はあった方がいいかもしれません。

宣伝: eslint-plugin-no-excess-property

オブジェクトリテラル以外に対しても余剰プロパティチェックをかけるプラグインです。

簡単に作れるかと思いきや、意外と手間取ってしまい、その分愛着も湧いてしまいました。お試しいただけると嬉しいです。

https://www.npmjs.com/package/eslint-plugin-no-excess-property

npmで公開しております。読み込みかたは一般的なルールと大体同じです。npmからインストールして、eslintrcなどでルールを追加してください。細々ではありますがしばらくメンテもするつもりなので、不具合等あったらissue建てていただけますと助かります。

npm install eslint eslint-plugin-no-excess-property --save-dev
// eslintrc.json
{
  "plugins": ["no-excess-property"],
  "rules": {
    "no-excess-property/no-excess-property": [
      "error",
      {
        "skipWords": ["UncheckedTypeName"],
        "skipProperties": ["uncheckedPropertyName"],
        "checkJsx": true,
        "checkClass": false
      }
    ]
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "sourceType": "module",
    "project": ["./tsconfig.json"]
  }
}

skipWordsには、余剰プロパティチェックをスキップしたい型名を指定してください。指定された型に対しては余剰プロパティチェックが行われなくなります。ossに合わせて5個くらい指定したら、結構現実的なレベルで開発が進められました。

たとえば、先ほど紹介したBlobとFileの余剰プロパティを許容してみます。

{
  "rules": {
    "no-excess-property/no-excess-property": [
      "error",
      {
        "skipWords": ["Blob", "Element", "RequestHandler"],
        "skipProperties": [],
        "checkJsx": true,
        "checkClass": false
      }
    ]
  }
}
const readData = (file: File) => {
  const reader = new FileReader();
  reader.readAsDataURL(file); // BlobがskipWordに指定されているのでエラーにならない
};

あまり複雑な型を扱わないプロジェクトに関しては、治安維持の選択肢の一つになる……かもしれません。よければお試しください。

Discussion