Help us understand the problem. What is going on with this article?

こちらCompilerAPI派出所any警察です👮‍♂️

これは TypeScript Advent Calendar 2019 23日目の記事です。

みなさん「any」使ってますか?え?お前はどうかって?それはもうね…つ、使ってませんよ(ドキドキ)👮‍♂️「あなたanyですね?署までご同行願います」🙋‍♂️「ぐぬぬ…マウスオーバーされてないのに何故バレた!」‬

隠れた any を探せ👮‍♀️!

TypeScript を使っていると、意図せず any が紛れこんでくることがあります。例えば、ライブラリが提供する関数が any をデリバリーしてきて、それがそのまま伝搬してしまう様なケースです。このように any が紛れてしまうと、grep ではもちろん、コードレビューの目もすり抜けてしまいます。

import { greet } from './utils'
const message = greet() // 隠れany
function hello() {
  const message = greet() // 隠れany
}

「any警察」がいれば、CIを落としたり・状況を可視化したり・カバレッジを取ったりなど、ワークフローに any を紛れ込ませない仕組みを導入することができます。

こちらany警察👮‍♂️!n件のanyをタイーホしました。

サンプルリポジトリはこちらになります。
https://github.com/takefumi-yoshii/typescript-anycop

~/g/typescript-anycop ❯❯❯ tree -L 1
├── app
└── lib

libが今回作成した「any警察」のコードです。appが any を探す対象プロジェクトです。libディレクトリでyarn startするとappプロジェクトに含まれる「any推論されてしまっている変数所在地」が報告されます。実行結果は下記のようになり、そのファイルに any の記述がなくても、import した関数起因の any 推論をきちんと検知していることが分かります。

$ ts-node src/index.ts
--------------------
totalVarDeclCount: 2
totalAnyDeclCount: 2
coverage: 0
--------------------
/~/app/src/index.ts:3 👮‍♂️ < 御用だ!
/~/app/src/index.ts:6 👮‍♂️ < 御用だ!
--------------------

/~/lib/src/index.ts:36
        throw message;
        ^
こちらany警察👮‍♂️!2件のanyをタイーホしました。

ts.TypeChecker で ts.Node の型をチェックする

TypeScript CompilerAPI は、Node.js で利用する TypeScript パッケージに含まれるAPI群です。ソースコード・TypeScript AST の相互変換が容易にできるほか、コードの診断(エラー検知)ツールに使われています。(CompilerAPI の初歩的な内容を書いてしまうと記事が長くなりすぎてしまうため割愛します。ご了承ください)

ASTを解析するときts.Nodeが基本となりますが、今回の目玉はts.TypeCheckerです。ts.Nodeインスタンスだけだと、型推論内容までは判別することができません。そこでts.TypeCheckergetTypeAtLocation関数を利用すると、判別できるようになります。次のようにts.TypeFlagsの分岐をAST解析の再帰関数に挟めばよく、あとは node の情報を出力するだけです。

ts.TypeFlagsは数値列挙型でもあり、Any は「1」で表現されます。似たようなツールを開発したくなったときは、ts.TypeFlagsの内訳を確認してみると良いかもしれません。たくさんあります。

const checker: ts.TypeChecker = program.getTypeChecker()
const { flags } = checker.getTypeAtLocation(node)
if (flags === ts.TypeFlags.Any) {
  // 「node: ts.VariableDeclaration」 の TypeFlags が
  // ts.TypeFlags.Any と一致した場合の処理
}

プルルプルルル「はい、こちらCompilerAPI派出所any警察です👮‍♂️え?package.json に怪しい diff があるPRが来たって?いくぞ中川!!」

any警察出動 🚴‍♂️〜🚓 🚓

ts-node で起動するエントリーポイントです。createProgram関数とgetAllAnyDiagnostics関数に主処理をまとめました。

import * as ts from 'typescript'
import * as path from 'path'
import { removeUndefined } from './arrayFilters'
import { createProgram } from './createProgram'
import { getAllAnyDiagnostics } from './getAllAnyDiagnostics'
import { log } from './log'
// ______________________________________________________
//
// 対象となるプロジェクトのパス
const srcDir = path.resolve('../app')
const program: ts.Program = createProgram(srcDir)
const checker: ts.TypeChecker = program.getTypeChecker()

// ts.Program から ts.SourceFile[] を捻出
const sources: ts.SourceFile[] = program
  .getRootFileNames()
  .map(fileName => program.getSourceFile(fileName))
  .filter(removeUndefined)

if (sources.length) {
  const diagnostics = getAllAnyDiagnostics(checker, sources)
  if (diagnostics.coverage !== 1) {
    // 少しでも any があればログ出力する
    log(diagnostics)
  }
}

createProgram関数

この関数は検査対象のプロジェクトからtsconfigを探し、ts.Programインスタンスを返します。CompilerAPI を利用したツールを作るとき、私はよくこの関数を流用しています。

import * as ts from 'typescript'
import { createConfigFileHost } from './createConfigFileHost'
// ______________________________________________________
//
export function createProgram(
  searchPath: string,
  configName = 'tsconfig.json'
) {
  // 調べる対象になるプロジェクトディレクトリから tsconfig を探す
  const configPath = ts.findConfigFile(
    searchPath,
    ts.sys.fileExists,
    configName
  )
  if (!configPath) {
    throw new Error("Could not find 'tsconfig.json'.")
  }
  // 見つけた tsconfig を元に
  // ts.ParsedCommandLine を取得
  const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
    configPath,
    {},
    createConfigFileHost()
  )
  if (!parsedCommandLine) {
    throw new Error('invalid parsedCommandLine.')
  }
  if (parsedCommandLine.errors.length) {
    throw new Error('parsedCommandLine has errors.')
  }
  // ts.Program を作成
  return ts.createProgram({
    rootNames: parsedCommandLine.fileNames,
    options: parsedCommandLine.options
  })
}

getAllAnyDiagnostics関数

この関数はプロジェクト全体のany診断を返します。診断収集までの流れを表にまとめました。手順1・2は、アプリケーション固有の診断収集をする簡単な処理に絞りました。手順3で any 推論されてしまっている Node を判別する処理が入っています。

手順 関数名 扱う対象 責務
1 getAllAnyDiagnostics ts.SourceFile[] プロジェクト全てのsrcファイルを診断し、any診断を返す
2 getSourceAnyDiagnostics ts.SourceFile ファイル単位で実行される処理。再帰関数でファイル内ASTを解析する
3 getAnyDiagnostics ts.Node Node単位で実行される処理。再帰関数呼び出しの度に実行される

手順1. getAllAnyDiagnostics

プロジェクト全てのsrcファイルを診断し、any診断を返します。今回は簡易的なサンプルのため「変数型推論がany」という条件に絞っています。プロジェクト全体の変数宣言総数と、any推論変数総数を報告します。

export function getAllAnyDiagnostics(
  checker: ts.TypeChecker,
  sources: readonly ts.SourceFile[]
) {
  let totalVarDeclCount = 0
  let totalAnyDeclCount = 0
  let allDiagnostics: string[][] = []
  sources.forEach(source => {
    const {
      srcDiagnostics, // string[]
      srcVarDeclCount // number
    } = getSourceAnyDiagnostics(checker, source)
    // 得られたany診断メッセージ配列
    allDiagnostics.push(srcDiagnostics)
    // src に書かれている変数を加算
    totalVarDeclCount += srcVarDeclCount
  })
  let _allDiagnostics = allDiagnostics.flat()
  // 得られたany診断メッセージ数
  totalAnyDeclCount = _allDiagnostics.length
  // ログ出力用のメッセージを整形
  const errorMessage = totalAnyDeclCount
    ? _allDiagnostics.reduce((a, b) => `${a}\n${b}`)
    : null
  // 全変数推論の非anyカバレッジを0〜1で表す
  const coverage = totalVarDeclCount
    ? 1 - totalAnyDeclCount / totalVarDeclCount
    : 1
  return {
    totalVarDeclCount,
    totalAnyDeclCount,
    allDiagnostics: _allDiagnostics,
    errorMessage,
    coverage
  }
}

手順2. getSourceAnyDiagnostics

ファイル単位で実行される処理です。再帰関数でファイル内ASTを解析します。

function getSourceAnyDiagnostics(
  checker: ts.TypeChecker,
  source: ts.SourceFile
) {
  let srcVarDeclCount = 0
  let srcDiagnostics: string[][] = []
  function visit(node: ts.Node) {
    const { diagnostics, varDeclCount } = getAnyDiagnostics(
      checker,
      source,
      node
    )
    // 収集した any診断を追加
    srcDiagnostics.push(diagnostics)
    // ファイル内変数宣言数を加算
    srcVarDeclCount += varDeclCount
    // visit関数の再帰呼び出し
    ts.forEachChild(node, visit)
  }
  // ts.SourceFile を起点に実行
  visit(source)
  return {
    srcDiagnostics: srcDiagnostics.flat(),
    srcVarDeclCount
  }
}

手順3. getAnyDiagnostics

ts.Node単位で実行される処理です。再帰関数呼び出しの度に実行されます。変数宣言Node(ts.VariableDeclaration)が期待値なので、ts.isXXX関数でNodeの型を絞りこんでいきます。ts.VariableDeclarationts.Nodeのサブタイプです。

function getAnyDiagnostics(
  checker: ts.TypeChecker,
  source: ts.SourceFile,
  node: ts.Node
) {
  let varDeclCount = 0
  let diagnostics: string[] = []
  // 変数宣言は `ts.VariableDeclarationList` を起点に調べる
  // var a, b = '' などの様に宣言できるため。
  // const a = '' でも、VariableDeclarationList から絞る必要がある。
  if (ts.isVariableDeclarationList(node)) {
    // var a, b などの場合にも向けて、イテレータで処理しなければいけない
    ts.forEachChild(node, child => {
      // 変数宣言であれば
      if (ts.isVariableDeclaration(child)) {
        // 変数宣言数をインクリメント
        varDeclCount++
        try {
          // ts.TypeChecker を利用し
          // ts.Node(child) に推論されている型を調べる
          const { flags } = checker.getTypeAtLocation(child)
          // ts.TypeFlags は enum
          if (flags === ts.TypeFlags.Any) {
            const start = node.getStart()
            const {
              line // any が見つかった行
            } = source.getLineAndCharacterOfPosition(start)
            const location = `${source.fileName}:${line + 1}`
            const message = `👮‍♂️ < 御用だ!`
            // ログ出力用の文字列
            const diagnostic = `${location} ${message}`
            diagnostics.push(diagnostic)
          }
        } catch (err) {
          // TODO: checker.getTypeAtLocation(child) で以下エラーがでる Node がある
          // TypeError: Cannot read property 'flags' of undefined
        }
      }
    })
  }
  return {
    diagnostics,
    varDeclCount
  }
}

最後に

このように、CompilerAPI ならではの検査器は手軽に作ることが出来ます。今回は「変数型推論がany」という条件に絞りました。型情報がコードに現れていない引数や、戻り型がanyに落ちてしまっている関数など、隠れ any はまだまだ身を潜めています。型安全な明日を守るため、頑張れ!any警察。戦え!any警察。(次回 any警察24時👮‍♂️

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした