React+CSSModulesな環境で、ちょびっとだけTS化した話

## overview
- すでにあるものは壊さないように、まずはReactコンポーネントをts化してみる
- それに伴ってcssの読み込みや、lint、テストがちゃんと動くようにする
- 結果的にbabelとeslintに寄せたら少ない修正で実施できた

## スタート地点
- ざっくり
    - React+Redux+thunkで構成したクライアントjsプロダクト
    - 少しずつ環境をモダン化しているので新旧色々混ざっている
- ビルド系
    - webpack+babelのes2015+
- linter
    - eslint+airbnb
- テスト系
    - jest
    - sinon
    - enzyme
- css
    - sass-loaderとcss-loader
    - 古い画面
        - Sass+BEMで構成
    - 新しい画面
        - Sass+CSSModulesで構成
- エディタ
    - IntelliJ

## 方針
最初はtsc+tslintを入れつつ、現状の実装を極力変更しないで済むように試みるも、tslint&eslintの重複・二重管理問題や、jestがなかなかうまく動かないなどの問題にぶつかり挫折。
色々見て回った結果、次のようにすることにしました。

 - `@babel/preset-typescript`を使って、トランスパイルは全てbabelに任せる
 - lintも`@typescript-eslint`を使ってeslintにまとめる
 - tscは型チェックのみやってもらう

参考にしたのはこの辺り
- [@babel/preset-typescriptを使ってTypeScriptを変換する](https://qiita.com/nacam403/items/edf3e2c8ff364aff910f)
- [typescriptをbabelでビルドしつつlintや型チェックもやりたい](https://qiita.com/tmzkysk/items/35ef6be9b5162a6bf2fe)
- [TypeScriptでこれは設定しとけっていうコンパイラーオプション](https://qiita.com/hiro-hori/items/8ed49788477b75596c40)
- [型強化の呪文。noImplicitAny](http://mk.hatenablog.com/entry/2017/09/06/115447)
- [tsconfig 日本語訳](https://qiita.com/alfas/items/539ade65926deb530e0e)
- [@typescript-eslint ことはじめ](https://teppeis.hatenablog.com/entry/2019/02/typescript-eslint)
- [Babelとの併用を止めてTypeScriptビルド一本化へ](https://1000ch.net/posts/2017/typescript-without-babel.html)


## 結果（設定変更編）
こんな感じの設定に辿り着きました。

### トランスパイル
#### パッケージ
typescript本体とbabelで変換するためのpresetを追加。

```diff
+    "@babel/preset-typescript": "7.3.3",
+    "typescript": "3.4.3",
```

#### tsconfig.json
方針通り、型チェック＋tscを通すのに最低限必要な設定に絞りました。
例えば`noUnusedLocals`などはeslintに任せるため設定していません。
型チェック系もeslintに任せられるものがありそうですが、、、追って検証します。

```json
{
  "compilerOptions": {
    /* Basic Options */
    "allowJs": true,            // 普通のjsファイルも扱えるようにする。ts/jsの共存に必要
    "noEmit": true,             // tscでファイル変換しない
    "jsx": "preserve",          // jsxをどう変換するか。tscがjsxを読み込むのに必要
    /* Strict Type-Checking Options */
    "strict": true,             // 型チェックを厳しく
    "noImplicitAny": true,      // 暗黙anyを許さない
    "noImplicitThis": true,     // thisを厳しくチェック
    "strictNullChecks": true,   // nullも厳しくチェック
    /* Additional Checks */
    "noImplicitReturns": true,  // 関数の戻り値だって厳しくチェック
    /* Module Resolution Options */
    "moduleResolution": "node", // モジュール扱い方法。型以外はES2015+にしたいのでnodeを指定
    "esModuleInterop": true     // ts形式ではなくECMAScript形式のimportで書けるようにする。
  }
}
```

#### wepback.config.js
`.ts`,`.tsx`を扱えるように変更しました。

```diff
 ...
     module : {
         rules : [
             {
-                test    : /\.jsx?$/,
+                test    : /\.(t|j)sx?$/,
                 loader  : 'babel-loader',
 ...
         resolve : {
-            extensions : ['.jsx', '.js', '.svg'],
+            extensions : ['.tsx', '.ts', '.jsx', '.js', '.svg'],
```

### lint
一旦は動くこと優先で、「これまでのルール＋@typescript-eslint/recommended」としました。
これまでのルールはairbnbをベースに使いにくいところを上書きして使っていました。

#### .erlintrc
```diff

 {
-    "parser": "babel-eslint",
-    "extends": "airbnb",
+    "parser": "@typescript-eslint/parser",
+    "parserOptions": {
+        "ecmaFeatures": {
+            "jsx": true
+        },
+        "project": "./tsconfig.json",
+        "tsconfigRootDir": "./"
+    },
+    "extends": [
+        "plugin:@typescript-eslint/recommended",
+        "airbnb"
+    ],
 ...
     "plugins" : [
+        "@typescript-eslint",
         "react",
         "react-hooks",
         "jest"
     ],
+    "settings": {
+        "import/resolver": "webpack"
+    },
     "rules": {
+        "@typescript-eslint/camelcase": [               // airbnbに合わせて設定
+            "error", { "properties": "never" }
+        ],
+        "@typescript-eslint/explicit-function-return-type": [ // 二重に型を書くのは手間なため
+            "error", {
+                "allowExpressions": true,
+                "allowTypedFunctionExpressions": true
+            }
+        ],
+        "@typescript-eslint/indent": [                  // 元々の慣習と違ったため
+            "warn", 4, { "SwitchCase": 1 }
+        ],
+        "@typescript-eslint/no-explicit-any": "error",  // anyを許さない
+        "@typescript-eslint/no-unused-vars": "error",   // 元々airbnbより厳しい設定がされていたため
 ...
+        "camelcase": "off",                             // typescript-eslinに任せるためoffに
 ...
-        "indent": ["warn", 4, {"SwitchCase": 1}],       // 元々の慣習と違ったため
+        "indent": "off",                                // typescript-eslinに任せるためoffに
 ...
+        "no-array-constructor": "off",                  // typescript-eslinに任せるためoffに
 ...
-        "no-unused-vars" : "error",                     // 元々airbnbより厳しい設定がされていたため
+        "no-unused-vars" : "off",                       // typescript-eslinに任せるためoffに
 ...
+        "react/jsx-filename-extension": ["error", {     // ts向けに拡張
+            "extensions": [".jsx", ".tsx"]
+        }],
 ...
}
```

##### parser
parserが代わり、オプションが増えました。
es2015+の読み込みができているのか若干不安ですが、今の範囲では大丈夫そうです。

##### extends/rules
extends同士で同じルールがある場合、後に書いた方のルールが勝つようで順番を変えるだけで判定がガラッと変わります。
そこでairbnbを先に書くバージョンと後バージョンと両方試してみたところ、後者の方が圧倒的に調整が少なくて済みました。
実施した調整としては[@typescript-eslint/recommended](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/src/configs/recommended.json)がeslintだけでは対応できなかったルールなどをoffにしてts対応版のルールで上書きしているものがあるため、それを復活させる上書きルールを追加しました。
`indent`を例に見るとこんな感じです。

```md
優先度高
↑  rules
↑    `@typescript-eslint/indent`に本当に適用したいルールを設定
↑    上記を適用したいので、airbnbの設定を消すために`indent`をoff
↑  airbnb
↑    `indent`設定が定義されているので上書き復活
↑  @typescript-eslint/recommended
↑    tsでindentルールが使えるように`@typescript-eslint/indent`をerrorに
↑    上記を適用したいので、eslintの`indent`をoff
優先度低
```

あとは`.tsx`ファイルでjsxを扱えるようにしています。

##### settings
`.tsx`のimportが一部だけ解決できなかったのでこちらの2番の方法で通しました。
[TypeScript + eslint で import/no-unresolved が出る場合の2つの対処法](https://qiita.com/euxn23/items/e2b9226ab1e02a9b1f20)

### テストランナー
babelに任せる方針に変えたらパッケージと設定を微修正しただけで済みました。
ほとんどそのままで行けるというのはとても素敵です。

#### パッケージ
```diff
-    "babel-jest": "24.1.0",
+    "babel-jest": "24.7.1",
```

下記のようなエラーが出て困っていたところ、babel-jestのバージョンを上げたら出なくなりました。

```shell
error TS2688: Cannot find type definition file for 'babel__core'.
error TS2688: Cannot find type definition file for 'babel__template'.
```

#### package.json
```diff
   "scripts": {
 ...
-    "lint": "eslint \"./src/js/**/*.js*\"",
+    "lint": "tsc --project . && eslint \"./src/js/**/*.[tj]s*\"",
```
チェックのみなのでtscはlintタスクに含めちゃいました。

#### jest.config.js
```diff
 module.exports = {
     collectCoverageFrom : [
-        'src/js/**/*.{js,jsx}',
+        'src/js/**/*.{js,jsx,ts,tsx}',
```

### css
テスト時、cssファイルのimportをスタブ化するのに`identity-obj-proxy`を使っていたのですが、これが動かなくなったため一旦[issue](https://github.com/keyz/identity-obj-proxy/issues/8#issuecomment-430241345)にあったコードを使わせてもらっています。

#### jest.config.js
```diff
     moduleNameMapper: {
-        '\\.css$' : 'identity-obj-proxy',
+        '\\.css$' : '<rootDir>/test/identity-obj-proxy-esm.js',
```

## 結果（TS化編）

### コンポーネント
単純なatomコンポーネントをts化しました。
[Atomic Design](https://www.amazon.co.jp/dp/B07CJ5TLK2)にも例で出てくるようなやーつです。

```tsx
//-----------------------------------------------------
// before: Title.jsx（抜粋）
//-----------------------------------------------------
import React       from 'react';
import * as styles from './title.css';

/**
 * Title
 *
 * @param {object} props
 * @param {string} props.level    Hタグのランク
 * @param {string} props.children
 * @return {ReactElement}
 */
export default (props) => {
    const Tag = `h${props.level}`;

    return (
        <Tag className={styles[Tag]}>{props.children}</Tag>
    );
};

//-----------------------------------------------------
// after: Title.tsx（抜粋）
//-----------------------------------------------------
import React       from 'react';
import * as styles from './title.css';

export interface Props {
    level: '1' | '2' | '3' | '4';
    children?: React.ReactNode;
}

enum HeaderLevel {
    h1 = 'h1',
    h2 = 'h2',
    h3 = 'h3',
    h4 = 'h4',
}

const Title: React.FC<Props> = (props) => {
    const Tag = HeaderLevel[`h${props.level}` as keyof typeof HeaderLevel];

    return (
        <Tag className={styles[Tag]}>{props.children}</Tag>
    );
};

export default Title;
```

加えて呼び出し側もts化したところ、無事にエラーを検知することができました♪
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5514/77a76964-12d3-fb79-c00d-b53c8fdbd32b.png)

### テストコード
こちらも問題なく動いております。
テストファイルではfunction-return-typeをoffにしちゃいました。

```tsx
/* eslint @typescript-eslint/explicit-function-return-type: "off" */
import React                       from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import Title, { Props }            from './Title';

describe('components/Title', () => {
    let wrapper: ShallowWrapper;
    describe('level', () => {
        beforeEach(() => {
            const props: Props = {
                level : '2',
            };
            wrapper = shallow(<Title {...props} />);
        });
        test('渡したlevelにあったhxタグが表示される', () => {
            expect(wrapper.find('h2').length).toBe(1);
        });
        test('渡したlevelにあったクラスが適用される', () => {
            expect(wrapper.prop('className')).toContain('h2');
        });
    });
});
```

### css
~~まだcssクラスが少なかったので手書きで書いてしまいました。
cssが多いファイルをts化する際には自動化も検討したいと思います。~~
[stack overflow](https://stackoverflow.com/a/44228423/6154016)を参考にcssファイル全般のアビエント宣言を書くだけで勝手に補完されるようになりました。

```ts
// declaration.d.ts
declare module '*.css' {
  interface IClassNames {
    [className: string]: string
  }
  const classNames: IClassNames;
  export = classNames;
}
```
補完される様子
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5514/9d1e46e7-2ea9-f175-1d78-e5cac623defc.png)

### パッケージ
エラーが出るたびに型定義パッケージを追加しました。
1つだけ得た知見としては、自分で対応済みのパッケージもあるということ。
例えば`redux-thunk`はv2.1.1から自分で対応済みですが、tscはそんなこと知らないので「とりあえず型定義パッケージいれてよー」と言います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5514/6efe0111-3690-f5c8-a54e-8647ae88a221.png)

そのまま信じてインストールしてもエラーは解決しません。
なぜならそのパッケージはインストールこそできるものの空っぽだからです（なんでや）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5514/1b6e2c99-0116-05f9-a324-d0d84b3c726e.png)
「公式で対応されてるからそっち使ってね」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5514/ec58803c-8d4a-d97c-a031-5b7521ced950.png)
この流れはもっと美しくなってほしいですね^^;
とりあえず今回の範囲ではreduxとthunkがこの事象にあたりアップデートされました。

```diff
-    "redux": "3.3.1",
-    "redux-thunk": "1.0.3",
+    "redux": "4.0.1",
+    "redux-thunk": "2.3.0",
```

## まとめ
はい、ということで無事に1コンポーネントとそれを呼び出すコンポーネント、そしてそれらのテストコードをts化することができました。
tscとbabel、tslintとeslintの併用ではなくbabel＋eslintに絞ったら思いのほか少ない変更でtypescriptを触れる環境ができました。
ゼロからの構築ではなくある状態から部分的にts化していく話なのでかなりニッチな情報ですが、似たような環境の人の参考になれば幸いです。

よーし、次はredux系をts化するぞー