これは 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.TypeChecker
のgetTypeAtLocation
関数を利用すると、判別できるようになります。次のように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.VariableDeclaration
はts.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時👮♂️