Mon, Nov 26, 2018

React + Reduxアプリケーションプロジェクトのテンプレートを作る ― その11: FlowからTypeScriptへ移行

React + Reduxアプリケーションプロジェクトのテンプレートを作る ― その11: FlowからTypeScriptへ移行

ReactReduxを学ぶために、開発環境というかプロジェクトテンプレートをスクラッチから作っている。 (最終的な成果はGitHubに置いた。)

前回Code SplittingFlowJestEnzymeをセットアップした。

前回でこのシリーズを終わりにするつもりだったけど、型システムをFlowからTypeScriptに移行したのでそれについて書く。

TypeScript

TypeScriptはMicrosoft製のAltJS。 もともとはCoffeeScriptのように言語の機能面(e.g. class構文やアロー関数)を補強しつつ、静的型付けをサポートする言語だったが、最近はECMAScriptが前者をカバーしてるので、後者を主な目的として使う人が多い。

2012年に誕生した言語で、同様に静的型付けをサポートするFlowよりも2歳ほど年上。

TypeScript vs Flow

個人的には、静的型付けだけを目的にするならAltJSである必要はなく、静的型付けだけを補完するFlowのほうが筋がいいような気がする。 TypeScriptはECMAScriptの進化に追従すべく、追加される機能や構文をサポートするためのエンハンスを繰り返しているが、そこはBabelに任せて静的型付けに注力したらいいような。

とはいえ、以下のような点を鑑み、結局TypeScriptを選択した。

  • TypeScriptの方が人気
    • GitHubのプロジェクトのスター数はTypeScriptが4万超えでFlowが2万弱。
    • 観測している限り、FlowからTypeScriptへ移行したというのは聞くが、逆は聞かない。
    • 人気があるということはコミュニティやエコシステムが大きいということ。
  • TypeScriptがノってる
    • BabelCreate React AppがTypeScriptをサポートして来ていて、なんだか時流にのっている。
  • Flowは型定義ファイルの管理方法が微妙
    • Flowはflow-typedという専用のツールを使ってファイルをダウンロードし、ダウンロードしたものをGitとかのVCSでバージョン管理するというやりかた。
    • TypeScriptはnpmで管理されてるので、Yarnでダウンロードもバージョン管理もできる。VCSのリポジトリに自前のコードしか入れないで済むのもいい。
  • TypeScriptの方が型定義ファイルが沢山提供されてる
    • Flowの10倍くらいある。
  • TypeScriptの方がエラーメッセージが分かりやすい
    • というのをどこかで聞いた。
  • Flowの方が段階的に型を導入できる、というのは昔の話
    • 今はTypeScriptもオプションによって段階的に導入できるというのが定評。
    • そもそも最初から型付けするならどうでもいい。
  • Flowの方が厳密な型チェックしてくれる、というのも昔の話
    • TypeScriptが追い付いてきて、今はほぼ同程度らしい。
  • TypeScript+VSCodeの開発体験が最高すぎるらしい
    • どっちもMicrosoft製なので。
  • TypeScriptの方がドキュメントが充実してる
  • TypeScriptの方が、いざというときにソースが読みやすい
    • TypeScriptはTypeScriptで実装されてて、FlowはOCamlで実装されてる。

参考:


前回の記事ではFlowを導入したんだけどTypeScriptに移行する羽目に。 FlowとTypeScriptとで型の表現方式や表現力にあまり差はなかったのでそこはまあ手間ではなかったんだけど、以下のような問題に対応する必要があった。

  • ビルド時にTypeScriptの方が時間がかかる。
  • TypeScriptのリンタであるTSLintが、FlowのESLintよりルールが貧弱
    • TypeScriptのコンパイラがチェックしてくれるからいいのかもしれないけど。
  • TypeScriptはAltJSなので、何かと連携するときに何かと面倒
    • Jestでユニットテストするときはどうするんだっけとか
    • プレーンJavaScriptと混在した環境ではTSLintとESLint併用しなければいけないんだっけとか

FlowからTypeScriptへの移行

脱Flow

とりあえずFlowを取り除く。

$ yarn remove flow-bin flow-typed @babel/preset-flow eslint-plugin-flowtype babel-eslint
$ rm -f .flowconfig

.babelrc:

 {
   "presets": [
     [
       "@babel/preset-env",
       {
         "useBuiltIns": "usage"
       }
     ],
-    "@babel/preset-flow",
     "@babel/preset-react"
   ],
   "plugins": ["styled-components", "@babel/plugin-syntax-dynamic-import"]
 }

.eslintrc.js:

 module.exports = {
   env: {
     browser: true,
     'jest/globals': true,
   },
-  parser: 'babel-eslint',
-  extends: ['airbnb', 'plugin:flowtype/recommended', 'prettier'],
-  plugins: ['flowtype', 'jest'],
+  extends: ['airbnb', 'prettier'],
+  plugins: ['jest'],
 };


環境はこれでよくて、あとは各.jsファイルと.jsxファイルから// @flowを消して、型情報も消す。 (型情報はTypeScriptでも同じようなのを書くので残しておいてもいい。)

TypeScript導入

パッケージインストール

以下のパッケージを入れる。

$ yarn add -D typescript @types/react @types/react-dom @types/react-redux @types/redux-logger @types/history @types/react-router-dom @types/uuid @types/styled-components awesome-typescript-loader

TypeScriptはv3.1.6、awesome-typescript-loaderはv5.2.1が入った。

TypeScriptの設定

TypeScriptの設定ファイルであるtsconfig.jsonはtscコマンドでテンプレートを生成できる。

$ yarn tsc --init

生成されたファイルをプロジェクトルートに置いて、ちょっといじって以下の感じに。 (jsonなのにコメント書ける…)

tsconfig.json:

{
  "compilerOptions": {
    /* Basic Options */
    "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
    "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
    "lib": ["es2015", "dom"] /* Specify library files to be included in the compilation. */,
    // "allowJs": true,                       /* Allow javascript files to be compiled. */
    // "checkJs": true,                       /* Report errors in .js files. */
    "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
    "sourceMap": true /* Generates corresponding '.map' file. */,
    // "outFile": "./",                       /* Concatenate and emit output to single file. */
    // "outDir": "./",                        /* Redirect output structure to the directory. */
    // "rootDir": "./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    // "composite": true,                     /* Enable project compilation */
    "removeComments": true /* Do not emit comments to output. */,
    // "noEmit": true,                        /* Do not emit outputs. */
    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    "strict": true /* Enable all strict type-checking options. */,
    "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
    "strictNullChecks": true /* Enable strict null checks. */,
    "strictFunctionTypes": true /* Enable strict checking of function types. */,
    "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
    "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
    "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,

    /* Additional Checks */
    "noUnusedLocals": true /* Report errors on unused locals. */,
    "noUnusedParameters": true /* Report errors on unused parameters. */,
    "noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
    "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,

    /* Module Resolution Options */
    "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
    // "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
    // "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
    // "typeRoots": [],                       /* List of folders to include type definitions from. */
    "types": [] /* Type declaration files to be included in compilation. */,
    // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */

    /* Source Map Options */
    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
  },
  "files": ["src/index.tsx"],
  "awesomeTypescriptLoaderOptions": {
    "useCache": true,
    "reportFiles": ["src/**/*.{ts,tsx}"],
    "forceIsolatedModules": true
  }
}

この設定ファイルのポイントは以下。

  • compilerOptions: tscのオプション。
    • target: コード生成方法をアプリを動作させる環境に合わせて設定するオプション。IE11を含むブラウザを想定してes5を設定。
    • module: モジュールコード(importとかexportとか?)の生成方法をアプリを動作させる環境に合わせて設定するオプション。大抵はcommonjsでいいけど、ダイナミックインポートを使うためにesnextを設定。(参考: https://stackoverflow.com/questions/45149091/typescript-use-dynamic-import-in-es5-with-bluebird)
    • lib: コンパイル時に取り込むライブラリを設定するオプション。ターゲットがes5の場合はデフォルトで[dom,es5,ScriptHost]が設定されるけど、それだとジェネレータとかダイナミックインポートを使う場合に「ERROR in [at-loader] TS2468: Cannot find global value 'Promise'.」というエラーがでる。のでes2015を追加する必要がある。document.getElementById()をするのにdomも必要だけど、ScriptHostは無くてもなんだかビルドできるので、["es2015", "dom"]を設定。
    • allowJs: デフォルトではTypeScript(i.e. .tsファイルと.tsxファイル)以外があるとエラーになるけど、このオプションをtrueにするとJavaScript(i.e. .jsファイルと.jsxファイル)も混在させられる。とりあえずデフォルトのfalseのままにしておく。
    • checkJs: allowJsをtrueにしていた場合、JavaScriptファイルのエラーチェックをするかを設定するオプション。エラーチェックをするかは、Flowみたいにソースごとにコメントで制御することもできるallowJsfalseにするので関係なし。
    • jsx: JSXのコードをどう処理するかを設定するオプション。プレーンなJavaScriptに変換してほしいので、reactを設定。
    • moduleResolution: モジュールの検索方法を設定するオプション。npmのパッケージ(i.e. node_modulesディレクトリ内のモジュール)を使うのでnodeを設定。(node以外にするケースはほとんどなさそう。)
    • types: コンパイル時に自動で取り込む型定義ファイルを設定するオプション。明示的にimportするものはここに書かなくてもいい。(ソースにimport 'hoge'と書けばnode_modules/node_modules/@types/内からhogeパッケージが検索される。)とりあえず自動で取り込むようなものはないので[]を設定。
    • esModuleInterop: Babel界との調停のためのtrueにしておくべきオプション。正直よくわからないが、Babelも併用するし、積極的に有効にすべきらしいのでtrueに設定。
  • files: includeexcludeと合わせて、コンパイル対象ファイルを指定するオプション。指定したファイルがimportするファイルは自動でコンパイル対象になる。ので、webpack設定のentryに設定しているもの(i.e. ["src/index.tsx"])をfilesに設定。(大抵のケースはこれでいいはず。)
  • awesomeTypescriptLoaderOptions: awesome-typescript-loaderのオプション。
    • useCache: ビルド速度向上のためにtrueを設定してファイルキャッシュを有効にする。キャッシュはプロジェクトルートの.awcacheディレクトリに保存されるので、これを.gitignoreに追加。
    • reportFiles: エラーチェックするファイルを設定するオプション。自分が書いたソースだけ見てくれればいいので、src/以下を設定。ちゃんと設定しておかないとnode_modules/以下のファイルのエラーチェックもしちゃう。
    • forceIsolatedModules: ビルド速度向上のためにtrueを設定してモジュールのリコンパイルを抑制する。モジュールがプレーンJavaScriptに変換済みのものだけならこれで問題ないはず。(つまり大抵はこれでいいはず。)

webpackの設定

awesome-typescript-loaderのドキュメントの通りにwebpackを設定する。

webpack.common.js:

 const path = require('path');
+const { CheckerPlugin } = require('awesome-typescript-loader');
 const packageJson = require('./package.json');

 module.exports = {
   entry: [`./${packageJson.main}`],
   output: {
     path: path.resolve(__dirname, 'dist'),
     filename: 'bundle.js',
   },
   module: {
     rules: [
       {
         test: /\.(js|jsx)$/,
         include: [path.resolve(__dirname, 'src')],
         enforce: 'pre',
         loader: 'eslint-loader',
         options: {
           configFile: './.eslintrc.js',
           failOnError: true,
         },
       },
       {
         test: /\.(js|jsx)$/,
         include: [path.resolve(__dirname, 'src')],
         enforce: 'pre',
         loader: 'stylelint-custom-processor-loader',
       },
       {
         test: /\.(js|jsx)$/,
         include: [path.resolve(__dirname, 'src')],
         loader: 'babel-loader',
       },
+      {
+        test: /\.(ts|tsx)$/,
+        include: [path.resolve(__dirname, 'src')],
+        loader: 'awesome-typescript-loader',
+      },
       {
         test: /\.(png|woff|woff2|eot|ttf|svg)$/,
         include: [path.resolve(__dirname, 'node_modules')],
         loader: 'file-loader',
       },
     ],
   },
   resolve: {
-    extensions: ['*', '.js', '.jsx'],
+    extensions: ['*', '.ts', '.tsx', '.js', '.jsx'],
     modules: ['node_modules'],
   },
+  plugins: [
+    new CheckerPlugin(),
+  ],
 };

とくにコメントなし。


awesome-typescript-loaderのドキュメントで推奨されているHardSourceWebpackPluginも導入しておく。 これを使うと、モジュールの中間キャッシュを生成して、二回目以降のビルドを高速化してくれる。

$ yarn add -D hard-source-webpack-plugin

webpack.dev.js:

 const path = require('path');
 const webpackMerge = require('webpack-merge');
+const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
 const webpackCommon = require('./webpack.common.js');

 module.exports = webpackMerge(webpackCommon, {
   mode: 'development',
   devServer: {
     contentBase: path.join(__dirname, 'public'),
     compress: true,
     hot: true,
     port: 3000,
     publicPath: 'http://localhost:3000/',
     historyApiFallback: true,
   },
+  plugins: [ new HardSourceWebpackPlugin() ],
 });

キャッシュはnode_modules/.cache/hard-source/に保存される。 たまにキャッシュのせいでビルド時とか動作時に問題が起こるので、そんなときはこれを消す。

フォーマッタの設定

Prettierは普通にTypeScriptをサポートしてるので、TypeScriptのファイルをフォーマット対象に加えるだけでいい。

package.json:

 (前略)
   "scripts": {
-    "format": "prettier --write **/*.jsx **/*.js **/*.css",
+    "format": "prettier --write **/*.jsx **/*.js **/*.tsx **/*.ts **/*.css",
     "build": "webpack --config webpack.prod.js",
     "test": "jest",
     "start": "webpack-dev-server --hot --config webpack.dev.js"
   },
 (後略)

リンタの設定

TypeScriptのリンティングは普通はTSLintを使う。 けど、すでにESLintがっつりセットアップしてしまったのでTSLintに移行するのが面倒。 また、将来的にJavaScriptのコードも混在させるかもしれないので、そのときESLintとTSLintの設定(ルール)を同時にメンテするのは面倒。

この記事によれば、typescript-eslint-parserを使えばそれらの面倒を回避できる。 typescript-eslint-parserはESLintのカスタムパーサで、TypeScriptのコードをESLintでリンティングすることを可能にする。

$ yarn add -D typescript-eslint-parser

typescript-eslint-parserはv21.0.1が入った。 これを使うようにESLintを設定する。

.eslintrc.js:

 module.exports = {
   env: {
     browser: true,
     'jest/globals': true,
   },
+  parser: 'typescript-eslint-parser',
+  parserOptions: {
+    jsx: true,
+    useJSXTextNode: false,
+  },
   extends: ['airbnb', 'prettier'],
   plugins: ['jest'],
+  settings: {
+    'import/resolver': {
+      node: {
+        extensions: ['.js', '.jsx', '.ts', '.tsx'],
+      },
+    },
+  },
+  rules: {
+    'react/jsx-filename-extension': ['error', { extensions: ['.tsx', '.jsx'] }],
+  },
+  overrides: [
+    {
+      files: ['**/*.ts', '**/*.tsx'],
+      rules: {
+        // Set 'no-unused-vars' to off to suppress errors on importing types.
+        // (e.g. error  'FunctionComponent' is defined but never used  no-unused-vars)
+        // Unused vars are checked by TypeScript compiler (at-loader) instead.
+        'no-unused-vars': 'off',
+      },
+    },
+  ],
 };

設定のポイントは以下。

  • parserにtypescript-eslint-parserを指定。
  • parserOptions.jsxtrueにするのはtypescript-eslint-parserの要件
  • parserOptions.useJSXTextNodefalseにするのはESLintのv4を使う場合のtypescript-eslint-parserの要件
  • settings[import/resolver].node.extensionsは、importするモジュールのパス解決の設定。デフォルトではJavaScriptの拡張子しか検索しないので、TypeScriptのモジュールが見つからなくてESLintが「Unable to resolve path to module './components/App' import/no-unresolved」みたいなエラーを吐く。これを防ぐためにTypeScriptの拡張子を追加する。(webpack.common.jsのresolve.extensionsと同じ拡張子を設定しておく。)
  • rulesでは、JSXファイルの拡張子名を制限するルールreact/jsx-filename-extensionを定義している。extendsしているairbnbの設定では.jsxだけになっているので.tsxを追加する意図。
  • overridesでは、宣言だけして使っていない変数をエラーにするルールno-unused-varsをTypeScriptに対して無効にしている。型をimportして使うコードを書くとエラーになっちゃうことがあるので。同様のチェックはTypeScriptのコンパイラがしてくれるので問題なし。


CSS(というかstyled-components)のリンタのstylelintTypeScriptに対応しているのでケアする必要なし。

JavaScriptをTypeScriptへ書き換える

とりあえず、src/以下の全ファイル(src/__tests__/以下は除く)について、拡張子を.js.jsxから.ts.tsxに変える。

型付けについては、TypeScriptのドキュメントの他、以下の記事を参考にした。

Reactコンポーネントの型

Function Componentは、propsの型をinterfaceで作って、React.FunctionComponentで型付ける。 型エイリアスtypeじゃなくてinterfaceを使うのは、公式が基本はinterfaceを使えと言ってるので。 因みにReact.FunctionComponentの代わりにReact.SFCを使ってるのをよく見るが、それは古い書き方。

例えば前回書いたMyDialogは以下のように書ける。

import React, { FunctionComponent } from 'react';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import PropTypes from 'prop-types';

// Props型の定義
interface Props {
  text: string;
  open: boolean;
}

// Function Componentの定義
const MyDialog: FunctionComponent<Props> = ({ text, open }) => (
  <Dialog open={open}>
    <DialogTitle>{text}</DialogTitle>
  </Dialog>
);

MyDialog.propTypes = {
  text: PropTypes.string.isRequired,
  open: PropTypes.bool.isRequired,
};

export default MyDialog;


Container Componentは、Store型をconfigureStore.ts辺りで定義しておいて、それをconnectするときのmapStateToPropsで使う。 Storeのプロパティは、Reducerの型付け(後述)のためにそれぞれStateとして型付けしておく。

以前書いたstateをStore型として定義すると以下のようになる。

Store型:

export interface HogeState {
  clicked: boolean;
}

export interface Store {
  hoge: HogeState;
}

これを使うコードは以下。 (以前の記事で書いたHogeButtonからの差分。)

src/containers/HogeButton.ts:

 import React from 'react';
 import Button from '@material-ui/core/Button';
 import { connect } from 'react-redux';
 import { hogeButtonClicked } from '../actions/actions';
+import { Store } from '../configureStore';

 const HogeButton = connect(
-  ({hoge}) => ({
+  ({hoge}: Store) => ({
     clicked: hoge.clicked
   }),
   {
     onClick: hogeButtonClicked,
   },
 )(Button);

 export default HogeButton;


Class Componentの型付け方法はtypescript-fsaに頼らないReact × Reduxの通りでよさそう。 正直あまり考えてない…。 Class Componentはまだ書いてないし、React Hooksが出てきて、Class Componentは非推奨になりそうでもあるし。

ReduxのActionの型

Actionの型は、reduxパッケージに基本的な型が定義されているのでそれを拡張して作る。 reduxパッケージのはstringtypeプロパティだけがある型なので、Flux Standard Action(FSA)的な形にするために、errorpayloadmetaの3つのプロパティをオプショナルで追加する。 errorbooleanで、payloadmetaジェネリクスで型を指定する。

このAction型から、typepayloadmetaの型を指定した型エイリアスを作ったり、extendsしたりして、個々のActionごとに具体的な型を作る。 typeプロパティは、文字列リテラル型としてAction Typeで型付ける。

例えば、以前書いたAction Creatorは以下のように型付ける。

src/actions/actions.ts:

+import Redux from 'redux';

 import {
   HOGE_BUTTON_CLICKED,
   HOGE_FETCH_SUCCEEDED,
   HOGE_FETCH_FAILED,
 } from './actionTypes';

+// Action型の定義。
+export interface HogeAction<Type, Payload = undefined, Meta = undefined>
+  extends Redux.Action<Type> {
+  error?: boolean;
+  payload?: Payload;
+  meta?: Meta;
+}

+// 型エイリアスによるAction型の具体化。
+// 「typeof HOGE_BUTTON_CLICKED」で文字列リテラル型を指定している。
+export type HogeButtonClicked = HogeAction<typeof HOGE_BUTTON_CLICKED>;
-export function hogeButtonClicked() {
+export function hogeButtonClicked(): HogeButtonClicked {
   return {
     type: HOGE_BUTTON_CLICKED,
   };
 }

+// payloadの型の定義。
+interface HogeFetchSucceededPayload {
+  hoge: string;
+}
+// インターフェースによるAction型の具体化。payloadプロパティを必須化している。
+export interface HogeFetchSucceeded
+  extends HogeAction<typeof HOGE_FETCH_SUCCEEDED, HogeFetchSucceededPayload, Object> {
+  payload: HogeFetchSucceededPayload;
+}
-export function hogeFetchSucceeded(payload, meta) {
+export function hogeFetchSucceeded(
+  payload: HogeFetchSucceededPayload,
+  meta: Object,
+): HogeFetchSucceeded {
   return {
     type: HOGE_FETCH_SUCCEEDED,
     payload,
     meta,
   };
 }

+export type HogeFetchFailed = HogeAction<typeof HOGE_FETCH_FAILED, Object>;
-export function hogeFetchFailed(payload) {
+export function hogeFetchFailed(payload: Object): HogeFetchFailed {
   return {
     type: HOGE_FETCH_FAILED,
     error: true,
     payload,
   };
 }

ReduxのReducerの型

Reducerの型はreduxパッケージのReducer型を使う。 このReducer型がジェネリクスで、引数のstateactionの型を受けるので、すでに定義したState型と具体Action型をimportして渡す。

例えば、以前書いたReducerは以下のように型付ける。

src/reducers/reducers.ts:

+import { Reducer } from 'redux';
+import { HogeState } from '../configureStore';
 import { HOGE_BUTTON_CLICKED } from '../actions/actionTypes';
+import { HogeButtonClicked } from '../actions/actions';

-export const hoge = (state = { clicked: false }, action) => {
+export const hoge: Reducer<HogeState, HogeButtonClicked> = (
+  state = { clicked: false },
+  action,
+) => {
   switch (action.type) {
     case HOGE_BUTTON_CLICKED:
       const newHoge = {
         clicked: true,
       };
       return Object.assign({}, state, newHoge);
     default:
       return state;
   }
 }

スクリプト以外をimportするコードを修正

JavaScriptやTypeScript以外のファイルをimportするコードを書くと、コンパイル時にエラーになる。

今まで書いた中で該当するのはsrc/fonts.tsでフォントファイルをimportしている箇所で、「TS2307: Cannot find module '../node_modules/typeface-roboto/files/roboto-lat in-300.woff'.」といったエラーが出る。

原因はwebpackでロードすべきものをtscでロードしちゃってるから。 フォントファイルはwebpack(のfile-loader)でロードすべき。

解決策は、import文はtscが処理しちゃうので、代わりにrequire関数を使うこと。

src/fonts.ts:

 import { createGlobalStyle } from 'styled-components';
-import roboto300 from '../node_modules/typeface-roboto/files/roboto-latin-300.woff';
+const roboto300 = require('../node_modules/typeface-roboto/files/roboto-latin-300.woff');

 const Fonts = createGlobalStyle`
   /* roboto-300normal - latin */
   @font-face {
     font-family: 'Roboto';
     font-style: normal;
     font-display: swap;
     font-weight: 300;
     src:
       local('Roboto Light'),
       local('Roboto-Light'),
       url('${roboto300}') format('woff');
   }
 `;

 export default Fonts;

これだけ。

Jestの設定

Jestを実行するときはwebpackを介さないので、別途TypeScript対応する必要がある。 純粋なTypeScriptプロジェクトでは普通ts-jest を使うみたいだけど、前回入れたbabel-jestで事足りるようなのでこっちを使う。

babel-jestは、Jest実行時にテストコードと関連モジュールをBabelで処理してピュアなJavaScriptにしてくれるやつ。 TypeScriptをBabelで処理できるようにするには、@babel/preset-typescriptを入れておく必要がある。

$ yarn add -D @babel/preset-typescript

.babelrc:

 {
   "presets": [
     [
       "@babel/preset-env",
       {
         "useBuiltIns": "usage"
       }
     ],
+    "@babel/preset-typescript",
     "@babel/preset-react"
   ],
   "plugins": ["styled-components", "@babel/plugin-syntax-dynamic-import"]
 }


で、jest.config.jsを二か所いじる。

一つは、JestがTypeScriptの拡張子を認識するように、moduleFileExtensionststsxを追加する。 もう一つは、Jestがbabel-jestを呼び出すように、transformにパターンを追加する。

jest.config.js:

 // For a detailed explanation regarding each configuration property, visit:
 // https://jestjs.io/docs/en/configuration.html

 module.exports = {
   // All imported modules in your tests should be mocked automatically
   // automock: false,

   // Stop running tests after the first failure
   // bail: false,

   // Respect "browser" field in package.json when resolving modules
   // browser: false,

   // The directory where Jest should store its cached dependency information
   // cacheDirectory: "C:\\Users\\kaitoy\\AppData\\Local\\Temp\\jest",

   // Automatically clear mock calls and instances between every test
   // clearMocks: false,

   // Indicates whether the coverage information should be collected while executing the test
   // collectCoverage: false,

   // An array of glob patterns indicating a set of files for which coverage information should be collected
   // collectCoverageFrom: null,

   // The directory where Jest should output its coverage files
   coverageDirectory: 'coverage',

   // An array of regexp pattern strings used to skip coverage collection
   // coveragePathIgnorePatterns: [
   //   "\\\\node_modules\\\\"
   // ],

   // A list of reporter names that Jest uses when writing coverage reports
   // coverageReporters: [
   //   "json",
   //   "text",
   //   "lcov",
   //   "clover"
   // ],

   // An object that configures minimum threshold enforcement for coverage results
   // coverageThreshold: null,

   // Make calling deprecated APIs throw helpful error messages
   // errorOnDeprecated: false,

   // Force coverage collection from ignored files usin a array of glob patterns
   // forceCoverageMatch: [],

   // A path to a module which exports an async function that is triggered once before all test suites
   // globalSetup: null,

   // A path to a module which exports an async function that is triggered once after all test suites
   // globalTeardown: null,

   // A set of global variables that need to be available in all test environments
   // globals: {},

   // An array of directory names to be searched recursively up from the requiring module's location
   // moduleDirectories: [
   //   "node_modules"
   // ],

   // An array of file extensions your modules use
-  // moduleFileExtensions: [
-  //   "js",
-  //   "json",
-  //   "jsx",
-  //   "node"
-  // ],
+  moduleFileExtensions: [
+    "ts",
+    "tsx",
+    "js",
+    "json",
+    "jsx",
+    "node"
+  ],

   // A map from regular expressions to module names that allow to stub out resources with a single module
   // moduleNameMapper: {},

   // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
   // modulePathIgnorePatterns: [],

   // Activates notifications for test results
   // notify: false,

   // An enum that specifies notification mode. Requires { notify: true }
   // notifyMode: "always",

   // A preset that is used as a base for Jest's configuration
   // preset: null,

   // Run tests from one or more projects
   // projects: null,

   // Use this configuration option to add custom reporters to Jest
   // reporters: undefined,

   // Automatically reset mock state between every test
   // resetMocks: false,

   // Reset the module registry before running each individual test
   // resetModules: false,

   // A path to a custom resolver
   // resolver: null,

   // Automatically restore mock state between every test
   // restoreMocks: false,

   // The root directory that Jest should scan for tests and modules within
   // rootDir: null,

   // A list of paths to directories that Jest should use to search for files in
   // roots: [
   //   "<rootDir>"
   // ],

   // Allows you to use a custom runner instead of Jest's default test runner
   // runner: "jest-runner",

   // The paths to modules that run some code to configure or set up the testing environment before each test
   // setupFiles: [],

   // The path to a module that runs some code to configure or set up the testing framework before each test
   // setupTestFrameworkScriptFile: null,

   // A list of paths to snapshot serializer modules Jest should use for snapshot testing
   // snapshotSerializers: [],

   // The test environment that will be used for testing
   // testEnvironment: "jest-environment-jsdom",

   // Options that will be passed to the testEnvironment
   // testEnvironmentOptions: {},

   // Adds a location field to test results
   // testLocationInResults: false,

   // The glob patterns Jest uses to detect test files
   // testMatch: [
   //   "**/__tests__/**/*.js?(x)",
   //   "**/?(*.)+(spec|test).js?(x)"
   // ],

   // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
   // testPathIgnorePatterns: [
   //   "\\\\node_modules\\\\"
   // ],

   // The regexp pattern Jest uses to detect test files
   // testRegex: "",

   // This option allows the use of a custom results processor
   // testResultsProcessor: null,

   // This option allows use of a custom test runner
   // testRunner: "jasmine2",

   // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
   testURL: 'http://localhost/',

   // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
   // timers: "real",

   // A map from regular expressions to paths to transformers
-  // transform: null,
+  transform: {
+    '^.+\\.jsx?$': 'babel-jest',
+    '^.+\\.tsx?$': 'babel-jest',
+  },

   // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
   // transformIgnorePatterns: [
   //   "\\\\node_modules\\\\"
   // ],

   // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
   // unmockedModulePathPatterns: undefined,

   // Indicates whether each individual test should be reported during the run
   // verbose: null,

   // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
   // watchPathIgnorePatterns: [],

   // Whether to use watchman for file crawling
   // watchman: true,
 };


以上でTypeScriptへの移行完了。