openapi-codegenにuseTypeImportsのオプションを追加した
はじめに
openapi-codegenは、OpenAPIのスキーマファイルからクライアントやサーバーのコードを自動生成するためのツールです。
今回、useTypeImportsというオプションを追加しました。どんなオプションなのか?なぜこのオプションを追加したのか?簡単に変更内容やその背景について紹介します。以下PRになります。
背景
以下のissueにも記載されていますが、TypeScript5.0からverbatimModuleSyntaxオプションが導入されました。
verbatimModuleSyntaxオプションが導入され、型専用のimportを扱う際のimport削除ルールが簡素化されました。この新しいオプションを有効にすると、型専用のimportを必ずimport typeとして明示的に書く必要があり、コンパイラによる自動的なimport削除が行われなくなります。
下記の@tsconfig/vite-reactのバージョンアップに伴って今回のエラーに遭遇しました。
エラーの例
'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かどうかを判別している部分が以下になります。
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を返します。
この関数でのおおまかな処理の流れは以下になります。
-
usagesオブジェクトでtypeとして使われたか or 値として使われたかを記録。 - ASTを探索(
checkNode)
2-1.ts.isIdentifier(node)で対象の識別子を見つける。
2-2. その親ノードを調べて、isValueUsageContextまたはisTypeUsageContextを呼ぶ。 - 探索終了後に型専用のインポートか、全く使われていないインポートなら
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