Nuxt.js の既存プロジェクトの型チェックを厳格化した話 ~vue-tsc と tsconfig/strictest 導入までの道のり~

ANDPADフロントエンドエンジニアの小泉(@ykoizumi0903)です。Vue / Nuxt での開発を行っています。

このテックブログでも、 Vue Composition API を使った開発にまつわる記事を過去に何件か書いていますが、現在担当しているプロダクトの開発に携わるようになって2年ほど経ち、いわゆる技術的負債と呼ばれるようなものも少しずつ増えてきました。

そういった状況を改善するアプローチの1つとして、Nuxt.js の既存リポジトリにおける型チェックのルールをより厳しい設定に変更する、という取り組みを2ヶ月ほどかけて行いました。

今回はその取り組みにおける道のりを振り返って、苦労したことや良かったことなどを書いてみたいと思います。

導入を決めるまでの流れ

きっかけは noUncheckedIndexedAccess を知ったことから

弊社のSlackにはtimes文化があり、私も業務に関係あったりなかったりすることを時々つぶやいています。

そこである日、「TypeScriptの配列の [0] の型が | undefined だったら良いのに」という内容の投稿をしたところ、同僚の1人から noUncheckedIndexedAccess オプションの存在を教えてもらいました。

zenn.dev

それまでの私は、「 strict: true を設定して、あとは余計なこと(オプションの上書き)さえしなければ最大限にTypeScriptの恩恵を受けられる」と思い込んでいたので、さらに厳格なオプションの存在を知ってかなり驚きました。

さらにその翌週、Zennのトレンドに tsconfig/bases の紹介! という記事が上がっており、そこでは noUncheckedIndexedAccess を含めて様々なオプションを一気にオンにする @tsconfig/strictest の存在が紹介されていました。

zenn.dev

1週間のうちに2回も同じような話を聞いたことで強く印象に残り、導入した方が良さそうだという気持ちに傾いていきました。

vue-tsc への乗り換えの必要性

もう1つ、以前から気にかかっていたこととして、<template> 内については型チェックなしで通してしまっているという問題がありました。

Nuxt TypeScript Module によってランタイムでの型チェックは行われていますが、チェック対象となっているのはあくまで <script> 内のみ。

拡張機能 Volar を利用してエディター上では型エラーが見えるようになっていますが、あくまでエディター上での警告のみであり、無視してコミットすることもできてしまいますし、実際に型エラーが無視されたままのコードも多々残っています。

Volar プロジェクトの一部として開発されている型チェックツールの vue-tsc を導入すればチェックできることはわかっていたのですが、既存のエラーを直すハードルの高さもあって二の足を踏んでいました。

そんな中で最後の一押しとなったのが、Nuxt TypeScriptと <script setup> の相性が悪いという点でした。

github.com

Vue 2でも <script setup> が使えるようになるこのプラグインは、Nuxt Composition API(0.28.0以上)の機能の一部として同梱されており、本来は追加設定なしで使えるのですが、Nuxt TypeScriptの TypeCheck でNo default export が表示されてしまうという問題があります。

github.com

この解決方法として、Nuxt TypeScript の TypeCheck を false にした上で vue-tsc を使ってチェックすることが推奨されていました。 つまり、script setup を使うのであれば Nuxt TypeScript の TypeCheck は使うべきではない、ということになります。

<script setup> 記法はコードの簡潔さという面での利点も大きく、将来的なNuxt 3への移行を考慮しても早いうちから導入して損のない記法なので、これを導入する際の障害はなるべく早めに取り除いておきたいと考えました。

このような状況が重なった結果、そろそろ重い腰を上げた方が良いという認識に至り、改善に向けて動き始めました。

ロードマップの作成

チーム内のフロントエンドメンバーにもこれらの2つのオプションに関する記事を共有して相談したところ、導入した方が良さそうだという方向で意見がまとまったため、 vue-tsctsconfig/strictest の導入をまとめて、「リポジトリの型安全性を高める」取り組みとして進めることとしました。

初めに事前調査として、とりあえず tsconfig/strictest を導入した状態で、何も直さずに vue-tsc を実行してみたところ、エラー数850という心が折れそうな数字が表示されました。

いきなりこれらのオプションを全てオンにしてエラーを取り除くのは無理そうだとわかったので、段階的に進めるためのロードマップを作り、簡単なドキュメントを作ってチーム内に共有することにしました。

モザイクがかかっているのは2つあるリポジトリの名前です。

現在、私が在籍しているチームのフロントエンドリポジトリは2つに分かれており、1つはメインのリポジトリ、もう1つがその中の一部の機能を切り出してプライベートモジュールとして独立させた関連ライブラリです。

(体裁上はライブラリという形でバージョンを切って管理しているものの、他のチームでは利用されていないので、実質的に同じプロダクトです)

そのため、まずはこの関連ライブラリの方に vue-tsc と tsconfig を導入し、バージョンを上げてリリースすることを目標としました。

理由としては、関連ライブラリのリポジトリはそもそものファイル数が少なく、実際にタイプエラーの修正に取り組む範囲としてちょうど良さそうなボリュームであったことと、影響箇所の洗い出しも比較的容易なので、一気に直してしまってもリグレッションテストがしやすいと考えたためです。

このようなドキュメントをベースに、進めたい内容とオプションの有用性についてチーム内に共有しました。チームのPM・QAの理解も得られたため、チーム内の自分ともう1人のメンバーの2人でタスクを分割しながら着手していくこととなりました。

といっても、緊急度が高いわけではなく、製品開発を止めるわけにもいかないので、機能改善のタスクが上がってきたら基本的にはそちらを優先しつつ、合間を縫って作業を進めていくこととしました。

サブリポジトリへの導入からリリースまで

関連ライブラリのリポジトリ(以下、サブリポジトリ)で vue-tsc を実行した結果、発生したエラーは80程度でした。これでも十分に多いのですが、800という数を見た後だと少なく感じます。

こちらの対応は一度のリリースで一気に直すことを前提として、CI設定とvue-tscの導入・templateタグのエラー解消までを私が行い、strictestの導入とそれにまつわるTypeErrorの解消は別のメンバーが行う、という形で分担しました。

基本的にはあくまで型エラーへの対応なので、 ?.?? などを使ったり、exactOptionalPropertyTypes エラーに対しては | undefined を明示的に足すなど、なるべく既存の動きを変えないように修正しつつ、それだけでは足りない部分は相談しながら修正を行っていきました。

Functional Component の props エラーを回避する

その中で個人的にかなりハマった箇所が1つだけあり、それが <template functional> を利用している部分でした。

サブリポジトリは高機能なツールを切り出していることもあり、パフォーマンス観点をかなり重視していたので、Functional Component を使うことで速度を向上させている箇所がありました。

これが vue-tsc ではエラーになってしまいます。何が怒られるかというと、Functional Componentではpropsに props.aaaa という書き方でしかアクセスできないのですが、これが「propsという名前のオブジェクトはない」となってしまうのです。

<template functional> 記法での Functional Component は Vue3 では廃止されているので、記法として vue-tsc が対応しないのは理解できますが、そうは言っても現時点では Functional でないとパフォーマンスに支障が出ることがわかっているので、今すぐに変更することはできません。

そこで苦肉の策として、vue-tscを騙すことにしました。

まず、Vue.extend() に渡す引数とは別に、props の型を IProps として定義します。

そうしたら、Vue.extendのジェネリクスに <{props: IProps}>を指定すると、vue-tsc は「propsという名前のオブジェクトをpropsに受け取っている(props.props が存在する)コンポーネントである」と解釈します。

export type IProps = {
  isSelected: boolean
}

const componentOptions: { props: RecordPropsDefinition<IProps> } = {
  props: {
    isSelected: {
      type: Boolean,
      default: false,
    },
  }
}

export default Vue.extend<{ props: IProps }>(componentOptions as any)

Vue.extend の引数で渡している props とは中身が異なるので当然エラーが出ますが、そこは as any で無理やり通します。

こうすることで、<template> 内のチェックには IProps が参照されるので props が型エラーにならず、props.isSelected は boolean として型チェックが行われます。一方で Vue.js としては componentOption.props に従って props を受け取ります。

この2つはあくまで別々の設定なので、それぞれを自分で一致させる必要がありますが、エラーになることなく <template><script> の両方で型補完が効くようになっています。

かなり苦しい方法ですが、型情報を追加しただけで実装方法を変えたわけではないことと、Functional Componentが1ファイルでしか使われておらず、この切り出された機能は頻繁に更新されるわけではないこと、 RecordPropsDefinition<IProps> の指定によって最低限キーの一致だけはチェックされること……などを踏まえ、Vue3が登場するまではこの状態で進めることとしました。

リリース直前に見つかった問題

修正が完了してエラーがなくなったところで、リリースに向けて準備を始めました。

基本的には挙動に変更が入っているわけではないので、問題ないだろうと思いつつも、念のため変更箇所を洗い出してQA・UATを行ってもらったところ、想定外に挙動が変わっている箇所が見つかりました。

理由は、 noUncheckedIndexedAccess の修正のために、以下のように Null合体演算子 を入れたことで、タイプエラーの解消だけでなく評価順序も変わってしまっていた、ということでした。

const arr: number[] = [1]

// 元のコード、noUncheckedIndexedAccessによりエラー
console.log(acc[0] + 10) // 11

// 一次修正後のコード、挙動が変わっている
console.log(acc[0] ?? 0 + 10) // 1

// 正しい修正後のコード
console.log((arr[0] ?? 0) + 10) // 11

幸い、全体的な修正箇所が洗い出せていたことから他の問題はなく、リリースは予定通り行うことができましたが、リグレッションテストの重要性とQAの有難みを改めて実感することとなりました。

メインリポジトリへの暫定的な導入

当初のロードマップでは、サブリポジトリの対応が終わったら次はメインリポジトリだ! くらいの甘い想定をしていたのですが、上記の問題に遭遇したこともあり、やはり一気に進めるのはリスクが高いということがわかりました。

そこでメインリポジトリについては、

  • 既存のコードはとりあえず // @ts-expect-error を付与して、動作を一切変えない形で tsconfig/strictest を導入する
  • vue-tsc についても、<template> へのチェックは一旦行わないまま、Nuxt TypeScriptから CI での型チェックへの移行のみ行う
  • このリリース完了後、少しずつ template タグのチェックと @ts-expect-error の除去をセットで行っていく

という方針に変更し、まずはリポジトリへの設定の導入だけを行うこととしました。

TSファイル への @ts-expect-error の一括挿入

まず、800以上のエラーを一旦どうにかしないことには @tsconfig/strictest を導入することはできません。

blog.nnn.dev

調べてみると、ありがたいことについ数ヶ月前にこんな記事が公開されていました。

まさにやりたいことそのものだったので、スクリプトはほとんどこの記事の内容をコピーしつつ、このリポジトリではJSXは使われていなかったので JSXの分岐だけ削除して使うことにしました。

Vueファイルの <script> 内への @ts-expect-error の一括挿入

TSファイルは上記の方法で対応できますが、Vueファイルに対しては同じ方法は使えません。

vue-tsc はあくまでエラー結果をコンソール上で返すだけで、上記のようなコンパイラAPIは提供されていませんでした。そこで、

  1. vue-tsc の実行結果をテキストファイルに保存

  2. テキストファイルを読み込んで正規表現で整形して同様のエラー結果を取得

  3. そのエラーがVueファイルで、かつ <script> タグ内に存在する場合のみコメントを挿入

  4. 最後にエラーを書き出したテキストファイルを自動削除

という処理を行うスクリプトを作成することにしました。

まず、package.json のスクリプトでは、vue-tscをコマンドライン上で実行し、結果を txt ファイルに書き出してから add-ts-expect-error.ts を実行します。

"add-ts-expect-error": "vue-tsc --noEmit > scripts/vue-tsc-error.txt; ts-node -O '{\"module\": \"commonjs\"}' scripts/add-ts-expect-error.ts",

この時、2つのコマンドを && ではなく ; で繋いでいるのは、 && は前のコマンドが正常終了した場合のみに次のコマンドを実行するものであり、 vue-tsc の実行結果がエラーなので次のコマンドに移ってくれなくなるのでした。考えてみれば当たり前のことですが、地味にここで数十分ハマりました。

次に、実行したテキストファイルを読み出して、出力結果から行数とファイル名を取り出し、上述の記事で ts ファイルに対して行ったのと同様の処理を行います。

import * as fs from 'fs'
import path from 'path'

// 事前に bash で `npx vue-tsc --noEmit > scripts/vue-tsc-error.txt` を実行している前提
// (ts-node で vue-tscを実行する方法がなさそうだったため)
const vueTscErrorText = path.resolve(__dirname, 'vue-tsc-error.txt')

// 出力されたエラーテキストから、ファイル情報・行数・エラーメッセージに整形
const allDiagnostics: string[][] = fs
  .readFileSync(vueTscErrorText, 'utf8')
  .replace(/(^\s.*$\n)/gm, '')
  .replace(/: error /g, ',')
  .replace(/\(/gm, ',')
  .replace(/,\d+\)/gm, '')
  .split('\n')
  .filter((str) => str)
  .map((tex) => tex.split(','))

// 同一ファイル内の複数エラーをMapでまとめる
const filePositionsMap: Map<string, { line: number; message: string }[]> =
  new Map()
allDiagnostics.forEach((diagnostic) => {
  const [file, start, message] = diagnostic
  if (!file || !start || !message) {
    return
  }

  // node_modules などのコンポーネントやtsファイルも vue-tsc でチェックされてしまうが、書き換えない
  if (!file.startsWith('app/') || !file.endsWith('.vue')) return

  const positions = filePositionsMap.get(file)
  if (!positions) {
    filePositionsMap.set(file, [{ line: Number(start), message }])
  } else {
    positions.push({ line: Number(start), message })
  }
})

// エラー行の前に"@ts-expect-error"を挿入して元のファイルを上書きする
filePositionsMap.forEach((positions, file) => {
  const fileTextLines = fs.readFileSync(file, 'utf8').split('\n')
  const scriptTagStartLine = fileTextLines.findIndex(
    (line) =>
      line.startsWith('<script lang="ts"') ||
      line.startsWith('<script setup lang="ts"')
  )
  const scriptTagEndLine = fileTextLines.findIndex((line) =>
    line.startsWith('</script>')
  )
  const newFileText = fileTextLines
    .map((lineText, index) => {
      // <script>タグ内のエラーのみ(templateタグでは ts-expect-error を使えないため)
      if (!(scriptTagStartLine < index && index < scriptTagEndLine)) {
        return lineText
      }
      const errorText = positions.find((pos) => pos.line === index + 1)?.message
      if (!errorText) {
        return lineText
      }
      const indent = lineText.match(/^(\s|\t)*/)?.[0] ?? ''
      return `${indent}// @ts-expect-error\n${lineText}`
    })
    .join('\n')
  fs.writeFileSync(file, newFileText)
  console.log('update', file)
})

fs.unlink(vueTscErrorText, (err) => {
  if (err) throw err
  console.log(`${vueTscErrorText} was deleted`)
})

このスクリプトによって、Vueファイルの script タグ内に対しても自動で @ts-expect-error を付与できるようになりました。

CircleCI でのチェック時のみ Vue Template をチェック対象外にする

<script> 内のエラーは対応できましたが、<template> 内のエラーについては @ts-expect-error を付与して対応することができません。

厳密に言えば、v-bindやマスタッシュの2行目以降であれば使える場合もあるのですが、その分岐があまりにもややこしく、またそれを行ったところで <template> 内のエラーを完全にゼロにできるわけではないので、初期段階では <script> のみチェックすることとしました。

タイミングの良いことに、 vue-tsc では今年3月に experimentalDisableTemplateSupport という、<template> 内のチェックをオフにするオプションが追加されていました。

https://github.com/johnsoncodehk/volar/issues/577

これを使うことで、 <script> 内のエラーのみをチェックするという、現行の Nuxt TypeScript と同じシステムでのチェックができました。

(ちなみに、既にお気づきの方もいるかもしれませんが、1つ前の「<script> への @ts-expect-error の一括付与」もこの設定を使えばわざわざ正規表現で <script> 内かどうかを判定する必要はありませんでした。😇)

しかし、このオプションをそのまま tsconfig に追加してしまうと、普段の開発中も Template タグ内のエラーが表示されなくなってしまいます。それでは困ります。

そこで、tsconfig.jsonexperimentalDisableTemplateSupport: true を追記して上書きするスクリプトを用意し、CircleCI上でのType Checkの直前に実行することにしました。

/** tsconfig.json を編集し、`<template>` 内の Type Check をオフにする(`<script>` 内はチェックする) */
import * as fs from 'fs'
import path from 'path'

const tsConfigJsonPath = path.resolve(__dirname, '../tsconfig.json')
const fileText = fs.readFileSync(tsConfigJsonPath, 'utf8')

const newFileText = fileText
  .split('\n')
  .map((line) => {
    if (line.includes(`vueCompilerOptions`)) {
      return `${line}
    "experimentalDisableTemplateSupport": true,`
    }
    return line
  })
  .join('\n')

fs.writeFileSync(tsConfigJsonPath, newFileText)

これをCircle CI のステップで呼び出すことで、「ローカルでは <template> でもエラーが出るが、CI上では <script> のみチェックが通ればマージできる」という設定を実現することができました。

ここまでの対応で、vue-tsc による型チェックがCIで行われ、新しく書くコードに対しては tsconfig/strictest が適用される、という状態になります。

今回はコメントの付与しか行っておらず、挙動に影響がないことは明らかだったので、こちらは主要導線のテストのみを行ってリリースを行うこととしました。

暫定対応から完全対応へ

ここまでで tsconfig/strictest の導入はできましたが、例外コメントが大量に残っているため、既存コードの安全性は改善されていません。

また、<template> タグ内のチェックが行われていないという状態も解消されていないので、早めに対応する必要がありました。

@ts-expect-error コメント削除のタスク化

まずは、「ts/vueファイルの @ts-expect-error コメントの削除と、<template> 内の型エラーの修正」というタスクを、機能・ページごとに複数のチケットに分割してバックログに積むことにしました。

通常の機能開発を完全に止めるのはリスクが高いのでそちらは並行しつつ、手が空いたタイミングで取って行ける状態を作っています。

機能ごとに分けて整理したことで、部分的にタスクを進められ、またQA・UATでの受け入れ条件も整理しやすくなりました。

変更されたファイルに限り Vue Template もチェック対象にする

Vue Templateのチェックについて、CI上では行わないようにしましたが、このままでは、特定のファイルについての修正が完了したかどうかもチェックできません。

そこで、変更のあったファイルに限りTemplateの型チェックも行いたいと考えました。

qiita.com

こちらの記事を参考にして現在のブランチとdevelopを比較して差分のあるファイルの一覧を出した後、それを --files 引数として受け取り、tsconfig.json の include を該当ファイルで上書きするスクリプトを作成しました。

/** TypeScript で チェックを行う対象のファイルを、引数 --files で渡されたファイルに限定する */

import * as fs from 'fs'
import path from 'path'
import * as yargs from 'yargs'
const { argv } = yargs.option('files', {
  description: 'tsconfig.jsonのfilesに記載する (複数ファイルはカンマ区切り)',
  demandOption: true,
})

if (typeof argv.files !== 'string') throw new Error('not string')
const targetFiles = argv.files
  .split(',')
  .filter((n) => n)
  .map((name, i, arr) => (i === arr.length - 1 ? `"${name}"` : `"${name}", `))

console.log('Type Check Files is \n', targetFiles.join('\n'))

const tsConfigJsonPath = path.resolve(__dirname, '../tsconfig.json')
const fileText = fs.readFileSync(tsConfigJsonPath, 'utf8')

const newFileText = fileText
  .split('\n')
  // experimentalDisableTemplateSupport を削除する(disable-vue-template-type-check から連続実行することを想定)
  .filter((line) => !line.includes('experimentalDisableTemplateSupport'))
  .map((line) => {
    if (line.includes('include')) {
      return `  "include": "app/@types/*.ts", [${targetFiles.join()}],`
    }
    return line
  })
  .join('\n')

fs.writeFileSync(tsConfigJsonPath, newFileText)

include: の最初に追加している "app/@types/*.ts", は、独自の型定義ファイル **.d.ts などを設置するディレクトリを常に読み込むための記述です。型ファイルの存在が前提になっている nuxt-typed-vuex のようなライブラリを利用したコードがエラーになってしまうことを防いでいます。

また、experimentalDisableTemplateSupport の削除は、本当は分けて別のファイルに書くべきなのですが、一時的な運用なので同じファイルに書いてしまいました。

package.jsonのscriptsには以下を追加しました。

"tsc-only-specified-files": "ts-node -O '{\"module\": \"commonjs\"}' scripts/tsc-only-specified-files.ts",

CircleCIの config.yml では次のようなステップを実行しています。

      - run:
          name: Disable Type-Check for Vue Template # VueTemplate内 の TypeErrorチェックを無効化する
          command: npm run disable-vue-template-type-check
      - run:
          name: run vue-tsc
          command: npm run vue-tsc
      - run:
          name: Enable Type-Check only for Changed Files
          command: git diff --name-only --diff-filter=ACMR origin/develop..origin/${CIRCLE_BRANCH} -- '*.vue' '*.ts'  | tr '\n' ',' | xargs -r npm run tsc-only-specified-files -- --files
      - run:
          name: run vue-tsc
          command: npm run vue-tsc

このように2回に分けて vue-tsc を実行することで、「<script> のエラー、または変更のあったファイルの <template> でエラーが見つかったらマージを禁止する」というチェックがCI上で可能となりました。

通常の開発でも、「どこかの機能を触ったら、そのファイルの <template> のエラーも直さなければマージできない」という状態になっているため、開発を続けていく中で自然にリファクタも進んでいくような流れを仕組み化しています。

総括

取り組みを振り返って

個人的に、今回の作業の進め方を振り返って良かったと思っているのは、事前の計画を大まかに立てつつも、適切なタイミングで見直しを行えたことと、複数人でチェック・相談することの重要さを改めて実感できたことです。

メインリポジトリへの導入については、私自身は最初は Vue Template 部分のエラーだけでも一気に直してからリリースする形でも良いのではないかと考えていました。

しかし、もう少し慎重に進めないとリスクが高いのではないか、という意見をチームのメンバーから出してもらったことで、コメントの付与のみを行ってリリースするように方針を変えました。それによってデフォルトブランチへのマージを最短で行えるようにもなり、安全にリリースもできるようになりました。

また、「変更を行ったファイルのみのチェック」も、ミーティングの中で、できればそういう形でのチェックができると良いですね、という話になり、「そんなことできるのかな?」と気になって調べてみたら意外とあっさりできた、という経緯で生まれたスクリプトでした。

1人で考えているとどうしても自分の持っている知識で完結する方法に偏ってしまうので、複数人で検討することの恩恵を大きく受けられたと思っています。

また、このようなリファクタ作業に工数を使うことを容認してくれたPMと、UATでデグレの発見までしっかり行ってくれたQAの力があったからこそ、ここまでスムーズに進められた取り組みでもありました。チームとして健全な動きができていることの再確認もできて良かったです。

リポジトリの現状

@ts-expect-error の除去作業はまだ始まったばかりですので、タイトルにあるような tsconfig/strictest の導入を完全に達成できたというわけではなく、800件以上あったエラーのほとんどはコメントで無視されているだけという状態ではあります。

しかし、少なくとも今後追加するコードに対しては厳格なチェックが効き、あとは愚直にエラーを潰していくだけというところまで進めることができたので、記憶が新鮮なうちに記事にまとめてみました。

実際にこのオプションが導入された状態での開発は、以前よりもエラーになり得る挙動を事前に検知しやすくなったと感じています。

チーム開発という面でも、型チェックのルールやマージ条件が厳格化されたことで、メンバー間でのコード品質に差が出にくくなり、コードレビューの負担も軽減されました。

最後に

Vue × TypeScriptの組み合わせでの開発は、ここ数年で漸進的に改善が進められてきたこともあり、歴史的な経緯で厳格なオプションを適用できないままになっているプロジェクトも多いのではないかと思います。

しかし、これから Vue 3/Nuxt 3 への移行も本格的に始まる中で、Vue × TypeScriptの組み合わせによって受けられる恩恵もますます大きくなっています。

そのようなプロジェクトでは、今回の記事で紹介したような、既存のコードへの影響を最小限にしながらオプションを厳格にする方法が役に立つ場合も多いのではないかと思います。この記事が読んでくださった方の助けになれば幸いです。

engineer.andpad.co.jp

そして、Vue プロジェクトでのこのような開発に興味を持たれた方は、ぜひお気軽にカジュアル面談などにご参加頂ければと思います!