📚

openapi-codegenにuseTypeImportsのオプションを追加した

に公開
2025/10/04

はじめに

openapi-codegenは、OpenAPIのスキーマファイルからクライアントやサーバーのコードを自動生成するためのツールです。

https://github.com/fabien0102/openapi-codegen

今回、useTypeImportsというオプションを追加しました。どんなオプションなのか?なぜこのオプションを追加したのか?簡単に変更内容やその背景について紹介します。以下PRになります。

https://github.com/fabien0102/openapi-codegen/pull/314

背景

以下のissueにも記載されていますが、TypeScript5.0からverbatimModuleSyntaxオプションが導入されました。
https://github.com/fabien0102/openapi-codegen/issues/311

verbatimModuleSyntaxオプションが導入され、型専用のimportを扱う際のimport削除ルールが簡素化されました。この新しいオプションを有効にすると、型専用のimportを必ずimport typeとして明示的に書く必要があり、コンパイラによる自動的なimport削除が行われなくなります。

下記の@tsconfig/vite-reactのバージョンアップに伴って今回のエラーに遭遇しました。

https://github.com/tsconfig/bases/commit/7c17bba655b6ba7f0a172deb80773f7ee75b4cc6

エラーの例

'xxx' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

この影響で、openapi-codegenで生成されたコードに対してもverbatimModuleSyntaxとの問題が生じるケースがありました。具体的には型だけを利用しているのに通常のimportが生成されてしまうため、verbatimModuleSyntaxと衝突してしまいます。

詳細

この問題を解決するために、新しくuseTypeImportsオプションを追加しました。
デフォルトで有効になっています。

import {
  generateSchemaTypes,
  generateReactQueryComponents,
} from "@openapi-codegen/typescript";
import { defineConfig } from "@openapi-codegen/cli";

export default defineConfig({
  github: {
    from: {
      source: "url",
      url: "https://api.apis.guru/v2/specs/github.com/1.1.4/openapi.yaml",
    },
    outputDir: "src/github",
    to: async (context) => {
      const filenamePrefix = "github";
      const { schemasFiles } = await generateSchemaTypes(context, {
        filenamePrefix,
        useTypeImports: false,
      });
      await generateReactQueryComponents(context, {
        filenamePrefix,
        schemasFiles,
        useTypeImports: false,
      });
    },
  },
});

従来

// person.ts
type Person = {
  id: number;
  name: string;
}
import { Person } from "./Person"; // Fooは型でしか使っていないのに通常import

useTypeImportsが有効の時

import type { Person } from "./person";

型と関数を同じモジュールからimportした際

import { type Person, getName } from "./person";

これにより、verbatimModuleSyntaxを有効にしているプロジェクトでもエラーなく利用できるようになります。

今回のPRでimport typeかどうかを判別している部分が以下になります。

https://github.com/fabien0102/openapi-codegen/blob/6c168296cae316a8ddf11b85eb8636e587656fd6/plugins/typescript/src/core/analyzeImportUsage.ts

TypeScript Compiler APIを利用して、インポートしたシンボルが型としてしか使われていないのか、値として使われているのかを判別するための処理です。
順を追って簡単に処理について説明します。

isValueUsageContext

const isValueUsageContext = (parent: ts.Node): boolean => {
  return (
    ts.isCallExpression(parent) ||
    ts.isObjectBindingPattern(parent) ||
    ts.isPropertyAccessExpression(parent) ||
    ts.isBinaryExpression(parent) ||
    ts.isReturnStatement(parent) ||
    ts.isExportSpecifier(parent) ||
    ts.isImportSpecifier(parent) ||
    ts.isAsExpression(parent) ||
    ts.isNewExpression(parent) ||
    ts.isArrayLiteralExpression(parent) ||
    ts.isObjectLiteralExpression(parent) ||
    ts.isPropertyAssignment(parent) ||
    ts.isVariableDeclaration(parent)
  );
};

ここでは、値として使われているかどうかを判定しています。
例えば以下のようなケースです。

  • 関数呼び出し hoge()
  • new Foo()
  • const x = Foo;
  • オブジェクトや配列リテラルに使われる場合

この関数がtrueを返却した際には、その識別子は値として参照されることになります。

isTypeUsageContext

const isTypeUsageContext = (parent: ts.Node, currentNode: ts.Node): boolean => {
  if (
    ts.isTypeReferenceNode(parent) ||
    ts.isTypeAliasDeclaration(parent) ||
    ts.isInterfaceDeclaration(parent)
  ) {
    return true;
  }

  if (ts.isIndexedAccessTypeNode(parent)) {
    return (
      ts.isTypeReferenceNode(parent.objectType) &&
      parent.objectType.typeName === currentNode
    );
  }

  return false;
};

最初に出てくるif文では、親ノードが型の文脈そのものかどうかを確認しています。

TypeReferenceNode...Foo<Bar>のような型参照
TypeAliasDeclaration...type MyType = Foo;のような型エイリアス宣言
InterfaceDeclaration...interface MyInterface { ... }のようなinterfaceでの型宣言

次にのif文では、親ノードがIndexedAccessTypeNodeかどうかを確認しています。
IndexedAccessTypeNodeはFoo["bar"]のように型レベルでのプロパティアクセスを表します。

analyzeImportUsage

/**
 * Analyzes how an imported symbol is used in the AST.
 *
 * @param nodes the AST nodes to analyze
 * @param importName the name of the imported symbol
 * @returns true if the import is used only as a type
 */
export const analyzeImportUsage = (
  nodes: ts.Node[],
  importName: string
): boolean => {
  const determineUsageType = (): "type-only" | "value" | "mixed" | "unused" => {
    const usages = { type: false, value: false };

    const checkNode = (node: ts.Node): boolean => {
      if (ts.isIdentifier(node) && node.text === importName && node.parent) {
        const parent = node.parent;

        if (isValueUsageContext(parent)) usages.value = true;
        if (isTypeUsageContext(parent, node)) usages.type = true;

        return !(usages.type && usages.value);
      }

      return !node.forEachChild((child) => !checkNode(child));
    };

    nodes.every(checkNode);

    if (!usages.type && !usages.value) return "unused";
    if (usages.type && usages.value) return "mixed";
    return usages.type ? "type-only" : "value";
  };

  const usage = determineUsageType();
  return usage === "type-only" || usage === "unused";
};

analyzeImportUsage関数は、指定したインポート名がASTの中で型としてのみ使われているのか、値として使われているのか、両方か、未使用かを判定します。
その結果、型専用または未使用なら trueを返し、それ以外(値や混在)はfalseを返します。
この関数でのおおまかな処理の流れは以下になります。

  1. usagesオブジェクトでtypeとして使われたか or 値として使われたかを記録。
  2. ASTを探索(checkNode)
    2-1. ts.isIdentifier(node)で対象の識別子を見つける。
    2-2. その親ノードを調べて、isValueUsageContextまたはisTypeUsageContextを呼ぶ。
  3. 探索終了後に型専用のインポートか、全く使われていないインポートならtrue判定とし、それ以外はfalseを返却する。

両方のケースとしては例えば以下になります。(ここでは、最低限の実装にするため、関数の中身は省略しています)

// myModule.ts
export function Bar() {}
// main.ts
import { Bar } from "./foo";

// 関数を実行
Bar();

// 型として利用
let barResult: ReturnType<typeof Bar>;

これらの識別をすることでimport文の出力を可変にしています。詳しくはPRを参照ください。

おわりに

今回TypeScriptのverbatimModuleSyntaxの対応にあたって、useTypeImportsというオプションを追加し、その背景や実装の詳細を紹介しました。openapi-codegenのようなコードを出力するようなケースではTypeScript Compiler APIや、今回は使用しませんでしたが、TypeScript Compiler APIをラップしたts-morphなどを利用するケースが出てくると思います。簡単ではありますが以上になります。

Discussion