kubell Creator's Note

株式会社kubellのエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

@typesパッケージをノールックマージする

こんにちは、株式会社kubellでフロントエンドエンジニアをしている石田(@ishida_2002)です。

この記事は kubell Advent Calendar 2025 の記事です🎄

私の所属しているフロントエンドチームでは Renovate を利用して依存関係の更新を行なっています。

今回は、Renovate が発行した PR をできるだけノールックマージするために、弊チームが取り組んでいる内容を紹介したいと思います。

はじめに

@types/ パッケージを使用していると、本体パッケージとのバージョン不整合という問題に遭遇することがあります。

例えば、以下のような状況です。

{
  "dependencies": {
    "react": "18.0.0"
  },
  "devDependencies": {
    "@types/react": "19.0.0"
  }
}

react は v18 ですが、@types/react は v19 の型定義を参照しています。この場合に v19 で追加された API を利用すると、型定義は存在するためビルドは通るものの、実行時には存在しない API を呼び出してエラーになってしまいます。

このような事態を避けるために @types/ パッケージの更新時は本体バージョンを確認していましたが、地味に面倒ですし、たまに忘れてマージしてしまうこともありました。

というわけで、自動化しましょう🤖

概要

以下の処理を行うスクリプトを作成します。

  1. package.json から @types/ パッケージを抽出してバージョンを取得
  2. 対応する本体パッケージのバージョンを取得
  3. バージョンを比較し、@types/ が本体より新しい場合にエラーを返す

なお、パッチバージョン(x.y.z の z)は比較対象に含めません。これは @types/ パッケージが独自のパッチリリースを行うことがあるためです。

実現方法

パッケージ名の変換

@types/ パッケージ名から本体パッケージ名への変換を行います。

スコープ付きパッケージ(@scope/package)の場合、DefinitelyTyped の命名規則では @types/scope__package となるため、__/ に戻す処理が必要です。

function convertPackageName(originalPackageName: string): string {
  if (originalPackageName.includes('__')) {
    return originalPackageName.replace('@types/', '@').replace('__', '/');
  }
  return originalPackageName.replace('@types/', '');
}

convertPackageName('@types/react') // 'react'
convertPackageName('@types/babel__core') // '@babel/core'

バージョンのフォーマット

セマンティックバージョニング(x.y.z)を major.minorx.y)形式に変換します。

const regex = new RegExp(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);

function formatVersion(version: string): string | undefined {
  if (!regex.test(version)) return undefined;
  const arr = version.match(regex);
  if (!arr) return undefined;
  return `${arr[1]}.${arr[2]}`;
}

formatVersion('1.2.3') // '1.2'

バージョンを比較

compare-versions というライブラリを利用してバージョンを比較します。

import { compareVersions } from 'compare-versions';

function canUpdateTypesPackageToNewerVersion(
  realVersion?: string,
  typesVersion?: string,
): boolean {
  if (!(realVersion && typesVersion)) return true;

  return compareVersions(realVersion, typesVersion) >= 0;
}

@types パッケージの抽出

devDependencies から @types/ で始まるパッケージを抽出して Map として返します。

function packageJsonToTypesDependenciesMap(
  packageJson: { devDependencies: Record<string, string> },
): Map<string, string | undefined> {
  const { devDependencies } = packageJson;
  const devDependencyWithVersions: Map<string, string | undefined> = Object.entries(
    devDependencies,
  )
    .filter(([name]) => name.startsWith('@types/')) // @types パッケージを抽出
    .map(([name, version]) => [convertPackageName(name), version])
    .reduce(
      (map, [name, version]) => map.set(name, formatVersion(version)),
      new Map<string, string | undefined>(),
    );

  return devDependencyWithVersions;
}

対応する本体パッケージのバージョンを取得

@types/ パッケージに対応する本体パッケージのバージョンを取得します。

function packageJsonToDependenciesMap(
  packageJson: { dependencies: Record<string, string> },
  typesPackagesMap: Map<string, string | undefined>,
): Map<string, string | undefined> {
  const { dependencies } = packageJson;
  const dependencyWithVersions = new Map<string, string | undefined>();
  typesPackagesMap.forEach((_, name) => {
    const version = dependencies[name] ?? '';
    dependencyWithVersions.set(name, formatVersion(version));
  });

  return dependencyWithVersions;
}

組み合わせる

まとめるとこんな感じです

import { readFile } from 'node:fs/promises';

// 略

async function main(): Promise<void> {
  const newPackageJsonPath = process.argv[2];

  const file = await readFile(newPackageJsonPath, 'utf-8');
  const newPackageJson = JSON.parse(file) as {
    devDependencies: Record<string, string>;
    dependencies: Record<string, string>;
  };

  // 1. @types パッケージを取得
  const typesDevDependencies = packageJsonToTypesDependenciesMap(
    newPackageJson,
  );

  // 2. 対応する本体パッケージを取得
  const correspondDependencies = packageJsonToDependenciesMap(
    newPackageJson,
    typesDevDependencies,
  );

  // 3. バージョンを比較し、`@types/` が本体より新しい場合にエラーを返す
  typesDevDependencies.forEach((typesVersion, name) => {
    const realVersion = correspondDependencies.get(name);
    if (!canUpdateTypesPackageToNewerVersion(realVersion, typesVersion)) {
      console.error('@types/ パッケージが本体のバージョンを上回っています');
      process.exit(1);
    }
  });
  console.log('OK 🎉');
}

CI/CD への組み込み

package.json に差分がある場合に発火するようにすれば OK です

# GitHub Actions

name: Check Types Version

on:
  pull_request:
    paths:
      - 'package.json'

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
      - run: npm ci
      - run: npx tsx check-types-version.ts <package.json path>

終わりに

これで @types/ パッケージは CI が通っていたらノールックでマージできます🎉

ぜひあなたのプロジェクトにも導入してみてください!

余談ですが、弊チームでは似たような理由から ESLint も自動テストしています。詳しくは別の記事があるのでぜひご覧ください。

creators-note.chatwork.com

明日は25新卒の @10yaPrestage さんです🎄