ts-morph を使って大量の TypeScript コードを機械的に書き換えた

[ts-morph](https://ts-morph.com/) という TypeScript コードの分析や書き換えを行えるツールがあります。
今回業務で大量の TypeScript ファイルを書き換える必要が出てきて利用してみたのですが、非常に便利かつ、使い方を学習するのも簡単だったので、実例を交えて紹介していこうと思います。

## どういう書き換えをしたか

実例をもとに紹介するので、最初に簡単に、今回やる書き換えがどんなものかを簡単に紹介します。
今回行ったのが、 GraphQL のコード生成ライブラリの移行作業 ([apollo-tooling](https://github.com/apollographql/apollo-tooling) → [GraphQL Code Generator](https://the-guild.dev/graphql/codegen)) に伴う書き換えです。

コード生成ライブラリが変わったことで、import するファイルが変わったり、import する名前が一部変わっているなどの違いがあり、それに対応するように書き換える必要がありました。

具体的には、こういうコードを、

```typescript
import FetchDataQuery from '~/graphql/generated/someQueryByQueryName.gql'
import { FetchData, FetchDataVariable, FetchData_items } from '~/graphql/generated/types/someQueryByQueryName'

export const SomeComponent = () => {
  const { data, refetch } = useQuery<FetchData, FetchDataVariables>(
    FetchDataQuery,
  )
  const items: FetchData_items[] = data.items
  // ...
)
```

↓

```typescript
import { FetchData, FetchDataDocument } from '~/graphql/generated/some-query-by-file-name'

export const SomeComponent = () => {
  const { data, refetch } = useQuery(
    FetchDataDocument,
  )
  const items: FetchData_items[] = data.items
  // ...
)

type FetchData_items = FetchData['items'][number]
```

こういう感じに書き換える必要がありました。
Qiita ではこうしたファイルが **100程度** あるので、全てを手作業で書き換えるのは大変です。

ここで利用したのが ts-morph です。

## ts-morph は何が出来るか

> ## Purpose
> Setup, navigation, and manipulation of the TypeScript AST can be a challenge. This library wraps the TypeScript compiler API so it's simple.
> https://ts-morph.com/

ts-morph は TypeScript ファイルの操作を行うためのライブラリで、 TypeScript compiler の機能をラップして、簡単に操作できるようにしています。
VSCode や TypeScript LSP を使っている人向けにいうと、あれらが出来るリファクタリング機能 (rename, import の追加) はだいたい ts-morph を使って利用できます。

### ts-morph で実際にコードを書き換える

ts-morph は公式ドキュメントが用意されていて ([ts-morph - Documentation](https://ts-morph.com/)) これを読みながら触ってみるだけでもだいぶ分かるのですが、いくつか (実際に自分が行った) ts-morph を使った書き換えを紹介していきます。

#### ts-morph でファイルを変更する際の流れ

ts-morph でファイルを操作する場合は基本的に以下のように、各ファイル (sourceFile) を取得して、操作を行い、保存する、という流れになります。

```typescript
import { Project } from 'ts-morph'

const project = new Project({ tsConfigFilePath: './tsconfig.json' })
project.getSourceFiles().forEach(sourceFile => {
  sourceFile.getClasses() // sourceFile 内の class 定義をすべて取得する

  sourceFile.getImportDeclarations((importDeclaration) => { // sourceFile 内の各 import を取得する
    const specifier = importDeclaration.getModuleSpecifierValue() // import ファイル指定先
    const newSpecifier = specifier.replace(specifier, `modified/${specifier}`)
    importDeclaration.setModuleSpecifier(newSpecifier) // import ファイル指定先を変更する
  })

  const importDeclaration = sourceFile.addImportDeclaration({
    defaultImport: "MyClass",
    moduleSpecifier: "./file",
  }) // import MyClass from "./file" を追加する

  sourceFile.save() // 行った操作をファイルに反映させる
})
```

#### import する名前を変更する

単純な書き換えだけでなく、変数名を変更する場合は、それを使用している箇所もセットで変更できます。(LSP が提供している rename と同じような機能です)

```typescript
import { FetchDataQuery } from '~/graphql/generated/types/some-query-by-file-name'

const { data, refetch } = useQuery(FetchDataQuery)
```

↓

```typescript
import { FetchDataDocument } from '~/graphql/generated/types/some-query-by-file-name'

const { data, refetch } = useQuery(FetchDataDocument)
```

以下のコードで、 import する名前と、その名前を使用している箇所を一括で変更できます。

```typescript
const namedImports = importDeclaration.getNamedImports()
namedImports.forEach(namedImport => {
  const currentName = namedImport.getText()

  if (/Query$/.test(currentName)) {
    const newName = currentName.replace(/Query$/, 'Document')
    const aliasName = namedImport.getAliasNode()?.getText() // import { FetchDataQuery as Query } from ... という形式の場合は Query が alias

    if (aliasName) {
      // Alias がある場合は一度 Alias を削除してから、import する名前を変更して Alias を再設定する
      namedImport.removeAlias()
      namedImport.setName(newName)
      namedImport.setAlias(aliasName)
    } else {
      // Alias がない場合はそのまま名前を変更する。この書き方だと利用箇所も一緒に変更される
      namedImport.getNameNode().rename(newName)
    }
  }
})
```

#### type 定義を追加する

生成したコードで一部 export しなくなった名前があったので、それに対応するために、type 定義を追加する必要がありました。

```typescript
import { FetchData_items } from '~/graphql/generated/types/some-query-by-file-name'
```

↓

```typescript
import { FetchData } from '~/graphql/generated/some-query-by-file-name'

type FetchData_items = FetchData['items'][number]
```

ts-morph には [Type 定義を追加するための function](https://ts-morph.com/details/type-aliases) が用意されているので、それを利用することで、上記のような type 定義を追加することができます。

```typescript
const namedImports = importDeclaration.getNamedImports()
namedImports.forEach(namedImport => {
  const currentName = namedImport.getNameNode().getText()
  if (/_/.test(currentName)) {
    const [basePart, ...props] = currentName
      .split('_') as string[]

    // FetchData_items -> FetchData['items'][number] という形式に変換する
    const aliasType = props.reduce((accType, prop) => {
      switch (prop) {
        case 'items':
          return `${accType}[${prop}][number]`
        default:
          return `${accType}[${prop}]`
      }
    }, basePart)

    // type 定義を追加する
    sourceFile.addTypeAlias({
      name: currentName,
      type: aliasType,
    })

    // import する名前を FetchData -> FetchData_items に変更する
    namedImport.setName(basePart)
  }
})
```

#### [Organize imports](https://devblogs.microsoft.com/typescript/announcing-typescript-2-8-2/#organize-imports) で import 定義を整える

コードを機械的に書き換えていくと、未使用の import が残ったり、追加した import の順番がバラバラになったり、など見栄えが悪いコードになってしまうことがあります。

```typescript
import FetchDataQuery from '~/graphql/generated/someQueryByQueryName.gql'
import { FetchData_items } from '~/graphql/generated/types/someQueryByQueryName'
import XXX from 'other-library'
import { FetchData } from '~/graphql/generated/some-query-by-file-name'
```

↓

```typescript
import { FetchData } from '~/graphql/generated/some-query-by-file-name'
import XXX from 'other-library'
```

ts-morph には [Organize imports](https://devblogs.microsoft.com/typescript/announcing-typescript-2-8-2/#organize-imports) という機能があり、未使用の import を取り除いたり、import の順番を整えたりすることができます。

```typescript
project.getSourceFiles().forEach(sourceFile => {
  sourceFile.organizeImports() // organize imports を実行する
  sourceFile.save() // 行った操作をファイルに反映させる
})
```

## コードを実際に書き換える際の Tips

ということで、 ts-morph の機能で必要な書き換えが行えることがわかりました。

これを使って実際に書き換えを行うのですが、チーム開発では他のメンバーと議論が発生したりするのもよくあることです。
チーム開発で書き換え作業を進めていく上で役立ったやり方を紹介していきます。

### Tips 1: 一連の書き換えを全てコード化して、冪等に行えるようにすると良い

書き換え作業は、基本的にコード化しておき、また、作業のロールバックを最初に行うなどして、スクリプトを冪等にしておくと良いです。

```bash
# 書き換え実施前にコードの状態を戻す
git restore --source=$BASE_COMMIT_ID --worktree -- javascripts/

# 一連の書き換え作業を行う
ts-node ./rewriter-with-ts-morph.ts
yarn run format || true
```

なぜこれをやるとよいかというと、以下のメリットがあるからです。

* 書き換え作業では、バグや考慮漏れの対処など、試行錯誤が必要になるので、リトライしやすくしておくと効率が良い
* 他のメンバーの変更への追従が容易になる
* 作業を全てスクリプト化することで、意図しない作業の混入を防げる
* レビューのタイミングでのフィードバックなど、後から出てきた要望にも対応しやすい
  * 作業を始める前に、ある程度方向性などを握っておくのももちろん重要ですが、実物を見てコメントが来ることもあるので、それに対応しやすいというメリットは大きいです。

特に、後から発生した細かいフィードバックに対応しやすいことが大きいと思います。
もちろん、作業を始める前の段階で、レビュアーとある程度すり合わせをしておくことも重要なのですが、細かい議論を変更後のコードを見ながら行えたり、議論を効率的に進めることが出来たり、良いことづくめでした。

### Tips 2: Linter の autofix, Formatter を併用すると良い

こういうコード書き換えツールでよくあるのですが、書き換えたコードや追加したコードは、indent などプロジェクトの書き方に沿ってないことが多いです。

書き換えツールが、プロジェクトの書き方に沿ったコードを追加する、というのは出来ないことが多いので、基本的には Linter の autofix, Formatter を併用すると良いです。