かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

Next.js ESLint で TypeScript のエラーを表示するようにしたい

TypeScript Next.js ESLint の設定でハマったついでにクリーンな環境で Next.js の ESLint で TypeScript のエラー表示させる方法までを試したのでメモ代わりに残しておきます

環境

  • Next.js 13.2.1
  • react 18.2.0
  • typescript: 4.9.5
  • eslint 8.35.0

Next.js のプロジェクト作成

$ npx create-next-app <PROJECT NAME> --typescript
✔ Would you like to use ESLint with this project? … Yes
✔ Would you like to use `src/` directory with this project? … Yes
✔ Would you like to use experimental `app/` directory with this project? … Yes
✔ What import alias would you like configured? … @/*

デフォルトの ESLint

.eslintrc.json

{ "extends": ["next/core-web-vitals"] }

TypeScript 的にやばいコンポーネントを作成する

React Component/src/MyComponent.tsx

import { FC } from "react";
type MyComponentProps = {
  name: string;
};
export const MyComponent: FC = ({ user }) => {
  const foo = user();
  const bar: any = user;
  return <div>{user.name} {user.age} {foo} {bar}</div>
};
  • MyComponentProps -> 使われてない変数
  • const MyComponent: FC = ({ user } -> user が any
  • const foo = user(); -> any 型に対してメソッドの呼び出し
  • const bar: any = user; -> any 型の宣言, bar は使用されてない変数
  • {user.name} {user.age} -> any 型のプロパティアクセス

Next Page /src/app/sample/page.tsx

import { NextPage } from "next";
const SamplePage: NextPage = ({ members }) => {
  return <div>{members.map((user) => (<div key={user.id} >{user.name}</div>))}</div>
};
export default SamplePage;
  • const SamplePage: NextPage = ({ members }) -> members は any 型
  • members.map((user) => (<div key={user.id} >{user.name}</div>))
    • -> any型の members に対して .map の実行
    • -> user は any 型
    • -> any 型の user のプロパティにアクセス

📝 デフォルトの ESLint では TypeScript のエラーが出ない

$ npm run lint
✔ No ESLint warnings or errors

.tsconfig.json"strict": true だが ESLint で any のエラーなどを拾うことができない状態…

Next.js の ESLint で TypeScript のエラーを表示するようにする

$ npm i -D @typescript-eslint/eslint-plugin

未使用の変数と any の型指定に warning を表示させる設定を .eslintrc.json に追加した

{
- "extends": ["next/core-web-vitals"],
+ "extends": [
+   "plugin:@typescript-eslint/recommended",
+   "next/core-web-vitals"
+ ],
+ "rules": {
+   "@typescript-eslint/no-unused-vars": "warn",
+   "@typescript-eslint/no-explicit-any": "warn"
+ }
}

👇 ESLint の実行

$ npm run lint
./src/MyComponent.tsx
3:6  Warning: 'MyComponentProps' is defined but never used.  @typescript-eslint/no-unused-vars
9:9  Warning: 'bar' is assigned a value but never used.  @typescript-eslint/no-unused-vars
9:14  Warning: Unexpected any. Specify a different type.  @typescript-eslint/no-explicit-any

意図したとおりに Warning が表示されている

any に対するルールの追加

  • no-unsafe-call … any型に対する関数呼び出し(anyVal()など)を禁止します。
  • no-unsafe-member-access … any型に対するメンバ呼び出し(anyVal.hogeやanyVal['hoge']など)を禁止します。
  • no-unsafe-return … anyかany[]が引数の返り値になることを禁止します。

cf. typescript-eslintの最新オプションno-unsafe-*を使って、TypeScriptの型リファクタリングを簡単に行った話 - ITANDI Engineer Blog

.eslintrc.json を編集する

{
  "extends": [
    "plugin:@typescript-eslint/recommended",
    "next/core-web-vitals"
  ],
+ "parserOptions": {
+   "project": "./tsconfig.json"
+ },
  "rules": {
    "@typescript-eslint/no-unused-vars": "warn",
    "@typescript-eslint/no-explicit-any": "warn",
+   "@typescript-eslint/no-unsafe-call": "error",
+   "@typescript-eslint/no-unsafe-member-access": "error",
+   "@typescript-eslint/no-unsafe-return": "error"
  }
}

※ 違いが見やすいように "error" の指定にした

no-unsafe-call, no-unsafe-member-access, no-unsafe-return のルールを使うには parserOptions.project にプロジェクトの tsconfig を指定する必要がある
指定してないと lint 実行時に次のようなエラーになる => Error while loading rule '@typescript-eslint/no-unsafe-call': You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.

👇 ESLint の実行

$ npm run lint
./src/MyComponent.tsx
3:6  Warning: 'MyComponentProps' is defined but never used.  @typescript-eslint/no-unused-vars
8:15  Error: Unsafe call of an `any` typed value.  @typescript-eslint/no-unsafe-call
9:9  Warning: 'bar' is assigned a value but never used.  @typescript-eslint/no-unused-vars
9:14  Warning: Unexpected any. Specify a different type.  @typescript-eslint/no-explicit-any
10:29  Error: Unsafe member access .name on an `any` value.  @typescript-eslint/no-unsafe-member-access
10:41  Error: Unsafe member access .age on an `any` value.  @typescript-eslint/no-unsafe-member-access

./src/app/sample/page.tsx
4:16  Error: Unsafe member access .map on an `any` value.  @typescript-eslint/no-unsafe-member-access
4:16  Error: Unsafe call of an `any` typed value.  @typescript-eslint/no-unsafe-call
4:49  Error: Unsafe member access .id on an `any` value.  @typescript-eslint/no-unsafe-member-access
4:60  Error: Unsafe member access .name on an `any` value.  @typescript-eslint/no-unsafe-member-access

先程は表示されなかった any 型のオブジェクトに対するアクセスにもルール通りエラーが表示されるようになった!

まとめ

  • Next.js はデフォルトでは TypeScript のエラーが npm run lint では表示されない
  • ミニマムな TypeScript のエラーを表示させるには @typescript-eslint/eslint-plugin をインストールして .eslintrc.json にルールを追加する
  • 使用するルールに依っては .eslintrc.jsonparserOptions.project プロパティを作成しプロジェクトの tsconfig.json のパスを指定する

自分で試した結果なので、勘違いなどあるかもしれません :pray:
おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]

TypeScript Next.js ESLint NextPage<PageProst> で missing in props validation エラーになる

チーム開発してる Next.js のプロジェクトで ページのコンポーネントの型を PageComponent: NextPage<PagePropsType> = ({ property }) => {} と書くと 'property' is missing in props というエラーが表示されると報告があり調べてたメモ

環境
  • Next.js 13.2.1
  • eslint ^8.34.0
  • typescript 4.9.5

状況

  • Next の PageComponent: PageComponent: NextPage<PagePropsType> => ESLint のエラーになる
  • Next 上で React のコンポーネント: Component: FC<PropsType> => ESLint のエラーにならない

結論: カスタマイズされていた eslint の設定に Next.js の推奨設定が入ってなかったのが原因だった

.eslintrc.js

{
- extends: [
-   "eslint:recommended",
-   "plugin:react/recommended",
-   "prettier",
- ],
- plugins: ["react"],
+ extends: ["next/core-web-vitals", "prettier"]
+ plugins: [],
}

Next.js にデフォルトで用意されている eslint-config-next には eslint-plugin-react, eslint-plugin-react-hooks, eslint-plugin-next が含まれるので react 関連は削除してしまう。 eslint:recommended は残しておいても良いと思うが Next がおすすめの設定を用意してくれているのでそれに乗っかって必要な箇所は rule 付けすれば良いかなという判断をしました


NextPages の型

export type NextPage<Props = {}, InitialProps = Props> = NextComponentType<
  NextPageContext,
  InitialProps,
  Props
>

NextPagesの型は上記のようになっており props の型をジェネリクス (Generic) で書くことは問題無さそう

'property' is missing in props エラーについて

上記のエラーは react/prop-types に依るもので、React でも Arrow 関数でジェネリクス (Generic) を使った場合にこのルールで引数の型がエラーになっていた issueがあったが既に修正されていた。
この修正はあくまで React の FCVFC に対するものだったので FC<PropsType> は問題なく Next.js の NextPage<pagePropsType> でエラーが出ているという状況だった

cf.

感想

エラーが出ているという状況だったので、react/prop-types のルール設定の問題かな〜と GitHub の issue を見てコードまで見に行って調べてたのですが、蓋を開けてみたらカスタマイズされていた ESLint の設定が Next.js 用ではなく React 用になっていたというオチでした。
途中から参加したプロジェクトの設定まわりで問題が起こった時、まだ初期段階ならクリーンな環境を作って設定ファイルの違いを見る事から始めた方が近道そうです… (2時間くらい溶かした…)

おわり


[参考]

TypeScript 定数を値に持つ配列で array.includes(value) しようとしたら Type error になる件

以前 Union 型に含まれるか判定するのに 配列を array.some で回して調べる方法を描いていました

array.includes() だと Type error になるという情報を見かけたので調べてみたのメモ

環境

TypeScript v4.9.5 (TypeScript Playground)

array.includes() だと Type error になる!

const arr = ['Ichigo', 'Aoi', 'Ran'] as const;
// const arr: readonly ["Ichigo", "Aoi", "Ran"]

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
  return arr.includes(val);
  // Argument of type 'string' is not assignable to parameter of type '"Ichigo" | "Aoi" | "Ran"'.
};

const str = 'Yurika';
isSoleil(str);

arr.include(val) の箇所で Argument of type 'string' is not assignable to parameter of type '"Ichigo" | "Aoi" | "Ran"'. という型エラーが発生しました

配列の中身が定数なら同様にエラーになる

const ICHIGO = 'Ichigo' as const;
const AOI = 'Aoi' as const;
const RAN = 'Ran' as const;
const arr = [ICHIGO, AOI, RAN];
// const arr: ("Ichigo" | "Aoi" | "Ran")[]

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
  return arr.includes(val);
  // Argument of type 'string' is not assignable to parameter of type '"Ichigo" | "Aoi" | "Ran"'.
};

解消方法 1. array.some() を使う

array.inclides を使わなくてもいいなら array.some() に置き換えてしまえば良い

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
- return arr.includes(val);
+ return arr.some((v) => v === val);
};

const str = 'Yurika';
isSoleil(str); // => false

解消方法 2. array.includes() に渡す引数を any 型にキャストしてしまう

array.prototype.include は次のように定義されている

declare global {
  interface Array<T> {
    includes<U extends (T extends U ? unknown : never)>(
      searchElement: U, fromIndex?: number): boolean;
  }
}

Yes, technically it should be safe to allow the searchElement parameter in Array<T>.includes() to be a supertype of T, but the standard TypeScript library declaration assumes that it is just T. For most purposes, this is a good assumption, since you don't usually want to compare completely unrelated types as @GustavoLopes mentions. But your type isn't completely unrelated, is it?
cf. javascript - Why does the argument for Array.prototype.includes(searchElement) need the same type as array elements? - Stack Overflow

string[] のような配列に number の値が含まれるかなんて調べないよね?って想定で array.includes() の引数は配列に含まれる型 (Array<T>T) であることとされているらしい
なので as const された配列や含まれる要素が定数の場合は、配列に含まれる型 = 定数 になるので string 型などを渡そうとするとエラーになってしまうみたい

なのだけど、array.includes() に渡す引数を as any で any 型にしてしまうと配列に含まれる型として担保されないのだけどコンパイラが通る

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
- return arr.includes(val);
+ return arr.includes(val as any);
};

const str = 'Yurika';
isSoleil(str); // => false

any 型は全ての型の親みたいなものなので、下記のような流れでコンパイラが通るのではないかと思う

includes<U extends (T extends U ? unknown : never)>
↓
includes<any extends (T extends any ? unknown : never)>
↓
includes<any extends unknown>

個人的な憶測だが any の場合は配列に含まれる型である可能性もあるので 100% 異なると言い切れないので受け付ける様になっているのではと思いました

追記: 2023-06-19 コメントで引数を as any にキャストするのは良くない。配列の側を as string[] でキャストしたほうが良いご指摘頂きました。

確かに、val: string として引数を受け取っている関数内で val as any にしてしまう気持ち悪さがありましたし、今回の例は定数とはいえ arrstring[] であることは自明なので、配列側の型をコンパイラに教えてあげる方が筋が良さそうだと感じました。

const ICHIGO = 'Ichigo' as const;
const AOI = 'Aoi' as const;
const RAN = 'Ran' as const;
const arr = [ICHIGO, AOI, RAN];
// const arr: ("Ichigo" | "Aoi" | "Ran")[]

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
- return arr.includes(val);
+ return (arr as readonly string[]).includes(val);
};
個人的に理解しきれていない点

arrstring[] なのは自明なのですが arr の定義が離れたところにあり、この isSoleil() 関数のコードの部分だけ見た時に arr as string[] とキャストしていることで返り値は val is SoleilType の筈だが万が一 arrSoleilType に含まれない文字列が入ってしまっていて、その文字列とマッチする引数が渡されれると val is SoleilType が変えるように見えてしまう可能性がるのではないか? (そう読み取れてしまう可能性を否定できないのではないか?) という疑問が湧きました

関数だけのファイルがあったとして

export const isSoleil = (val: string): val is SoleilType => {
  return (arr as readonly string[]).includes(val);
  // このコードでは arr がコピーではなく参照として使われているので、
  // arr にもし SoleilType が含まれてしまっていたら… と読み取れてしまわないか?
};

TypeScript 歴がめちゃめちゃある訳ではないので、ちょっと肌感がわからないのですが引数を最初から any にしてしまえば引数は何でも取れるが、必ず SoleilType にマッチるものしか返されないとしか読み取れないとならないかな?と感じました。

const isSoleil = (val: any): val is SoleilType => {
  return arr.includes(val);
};

引数の型が初めから any であれば、個人的にはコードの見た目上 SoleilType の配列に val が含まれるかが返されるように読み取りやすそうに感じました。
いずれにせよ arr が参照地なので必ずしも SoleilType である確証がないので良いコードではないのですが…

この辺り肌感覚がないので教えていただけると嬉しいです!

まとめ

配列に含まれている値かチェックしたい時は

1 array.some() を使う
2 array.includes() を使う場合、array が定数の配列なら引数を as any にキャストして渡す
2-1 array.include() を使う場合、array が定数の配列の型が自明であれば、配列を as some[] でキャストする
2-2 array.includes() を使う場合、array が定数の配列なら関数の引数を any として受け取ってしまう

とおぼえておけば良さそうです
個人的には array.some() で良いんじゃないかな〜という印象

おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]