Code generator開発記(Part 2)
!
この記事は2023年7月3日に執筆されたものです。
こんにちは。Inflabのフロントエンドエンジニア、Lucasです。
前回の記事では、code generatorの必要性と、自前で実装するに至るまでの悩みを共有しました。今回は、code generatorをはじめ、schema validator、type guardをどのように実装したのかを簡単にご紹介したいと思います。
最初にお伝えしておくと、これはとてもシンプルなアイデアで、実装に複雑な技術は必要ありません。その前提で読み進めていただければと思います。
1. 目標と必要な機能
まず、前回の記事で立てた目標について改めて考えてみます。私はcode generatorを実装する前に、次のような目標を立てました。
ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。
フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。
フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。
APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。
バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。
バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。
特に最後の目標は、Elixirのdoc testの概念を借りたものです。昨年取り組んだ個人プロジェクトでテストを書いた際の感触が良く、GraphQLがサポートしているようなschemaの整合性検証も、近い形なら実現できるのではないかと期待していました。特に私たちのバックエンドチームはテストを書くことを非常に重視しているので、OpenAPI specificationと実際のAPIの結果をテストする手軽な方法を提供できるだろうと考えました。
以下、OpenAPI specification JSONはOASと略します。
2. 使用したライブラリ
まず、実装に使ったライブラリを紹介します。おそらくほとんどの方がご存じだと思います。
1. 実装
inversify
機能が増えるほど、コードベースは必然的に大きく複雑になるだろうと考えました。また、複数のオプションをサポートする予定だったので、実行時に動く実装も変わりうると見ていました。
そのため機能間の依存関係が複雑になりそうだと考え、悩んだ末に依存性注入(DI)のためのIoCコンテナを探すことにしました。私はinversifyを選びましたが、もう少し軽量に使いたいのであれば、typediという選択肢もあります。
json-schema-to-typescript
事実上、code generatorの中核となるライブラリです。Code generatorのほぼすべての実装は、このライブラリから望む結果を得るためのオブジェクトを作る作業だと言っても過言ではありません。例えば、次のようなJSON schemaがある場合、
{
"title": "Example Schema",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
},
"hairColor": {
"enum": ["black", "brown", "blue"],
"type": "string"
}
},
"additionalProperties": false,
"required": ["firstName", "lastName"]
}
次のように型を生成します。
export interface ExampleSchema {
firstName: string;
lastName: string;
/**
- Age in years
*/
age?: number;
hairColor?: 'black' | 'brown' | 'blue';
}
lodash
object-traversal
js-yaml
オプションをYAML形式で受け取るつもりでインストールしました。今思えば、普通にJavaScriptで受け取っても問題なかったと思います。
jsonschema
Code generatorが受け付けられるオプションを検証するために選びました。オプション関連のコードがif ~ elseだらけになるのを避けたかったのです。
2. ビルド、デプロイ
TypeScript
tsup
esbuildベースのビルドツールです。速くて設定が簡単なので選びました。
Release Please
ライブラリではありませんが、このGitHub Actionのおかげで手軽にデプロイできたので紹介します。
コミットメッセージの中にconventional commit形式で書かれたものがあれば、pull requestの作成からデプロイまで自動で行ってくれます。特にchange log、release、git tagなども自動で管理してくれるので、開発者はコードだけに集中できます。
ちなみに、Inflabのフロントエンドチームでは、monorepoで構成されたパッケージはchangesetsで、単独のリポジトリで運用しているパッケージはRelease Pleaseで管理しています。
commitlint
上で説明したRelease Pleaseの動作の仕組み上、開発者のミスを防ぐために導入しました。コードの変更がコミットされる前にコミットメッセージを検査し、conventional commitの形式に従っていなければコミットできないようにします。
コミットメッセージの一貫性を保ち、デプロイのためにコードを変更したのに、コミットメッセージの誤りでパッケージがデプロイされないという問題を防ぐのが狙いです。
3. テスト
Jest
3. 実装の詳細
Code generatorは、大きく3つの段階に分けて実行されます。1つ目は前処理の段階です。正しいオプションが渡されたかを検証し、OASのJSONファイルを読み込みます。続いて、ユーザーが望む前処理ロジック(Pre process)を実行して、読み込んだOAS JSONを加工します。
2つ目の段階では、Taskと呼ばれる作業単位が並列に実行されます。どのタスクを実行するかは、そのタスクのオプションが正しく入力されているかどうかによって決まります。
ReactQueryTaskでは、前処理済みのOAS JSONから生成物に使うオブジェクトを作り、適切な名前と対応付けます。Query、url params、response、request bodyなど、必要と思われる要素をすべて抽出し、型とhooksに変換します。
TypeGuardTaskでは、ReactQueryTaskで作られたインターフェースを検証するtype guard関数を作ります。
SchemaValidatorTaskは、OAS JSONで宣言されたすべてのschemaを検証できるstandaloneのAjvインスタンスを生成します。Ajvについては、後ほど改めて詳しく紹介します。
Taskの並列実行が終わったら、最後の段階として、指定された場所へファイルを書き出します。フローチャートで表すと、次のようになります。
1. オプションの入力を受け取る
ユーザーから希望するオプションをYAML形式で受け取るように構成しました。そのオプションは次のとおりです。
loader: fetch | file [required]
schema: string [required]
output-dir: string [required]
react-query: [optional]
output: string [required]
fetcher: string [required]
version: 3 | 4 [optional] default: 3
validator: [optional]
output: string [required]
typeguard: [optional]
output: string [required]
preprocess: [optional]
filterDeprecated: boolean [optional] default: false
extractEnum: boolean [optional] default: false
apiPaths:
- path:
string [required]
exact:
boolean [optional]
tags:
- string [required]
2. 前処理
1. オプションの検証
YAMLファイルの有無の確認、node argumentsの入力チェック、kebab-caseのオプションのcamelCaseへの変換など、I/Oと副作用はConfigProviderが担当するようにしました。
一方、react-query、preprocessなど各オプションのパース、検証、値の設定は、ConfigLoaderを実装したオブジェクトが担います。各オブジェクトがパースしたオプションは、ConfigProviderに渡されます。こうしたのは、オプションごとの変更の影響を互いに切り離すためです。
例えば、react queryを担当するクラスは次のように実装されています。
import Ajv from 'ajv';
import type ConfigLoader from './ConfigLoader';
import type { JSONSchemaType } from 'ajv';
import { isNil } from 'lodash';
import { InvalidConfigException } from '../utils/exception';
type ReactQueryConfigOptions = {
output: string;
fetcher: string;
version?: number;
removeApiEndpointPrefix?: boolean;
};
type YamlReactQueryConfig = {
reactQuery?: ReactQueryConfigOptions;
};
/**
- 1. react-queryオプションのjson validationロジック
*/
const schema: JSONSchemaType<YamlReactQueryConfig> = {
type: 'object',
properties: {
reactQuery: {
type: 'object',
nullable: true,
properties: {
output: { type: 'string' },
fetcher: { type: 'string' },
version: { type: 'number', nullable: true, default: 4, minimum: 3, maximum: 4 },
removeApiEndpointPrefix: { type: 'boolean', nullable: true },
},
required: ['output', 'fetcher'],
},
},
additionalProperties: true,
};
const validate = new Ajv({ useDefaults: true }).compile(schema);
export type ReactQueryConfig = ReactQueryConfigOptions | false;
export default class ReactQueryConfigLoader implements ConfigLoader<ReactQueryConfig> {
/**
- 2. react-queryのオプションの検証
*/
load(optionCandidate: unknown) {
if (!validate(optionCandidate)) {
throw new InvalidConfigException('ReactQueryConfigLoader: not a react query config');
}
/**
- 3. react-queryのオプションを返す
- 戻り値の型はReactQueryConfigOptions | false
*/
if (isNil(optionCandidate.reactQuery)) {
return false;
}
return optionCandidate.reactQuery;
}
}
ReactQueryConfigLoaderでパースしたオプションは、ConfigProviderのパブリックメソッドとして提供されます。
export class ConfigProviderImpl implements ConfigProvider {
private loader: Loader;
private options: Options;
constructor() {
/**
- 1. yamlファイルの読み込み
*/
const yamlCandidate = this.loadConfig();
const reactQueryConfigLoader = new ReactQueryConfigLoader();
/**
- 2. react-queryのオプションのパースを依頼
*/
const reactQueryConfig = reactQueryConfigLoader.load(yamlCandidate);
this.options = {
reactQuery: reactQueryConfig,
};
}
private loadConfig() {
const flags = args.parse(process.argv);
const yamlPath = resolve(process.cwd(), flags.config);
if (typeof yamlPath !== 'string') {
throw new InvalidPathException('ConfigProvider: failed to open yaml config file');
}
const yamlConfig = camelizeKeys(yaml.load(readFileSync(resolve(__dirname, yamlPath), 'utf-8')));
return yamlConfig;
}
getOptions(): Options {
return this.options;
}
/**
- 3. オプションが必要なオブジェクトにオプションを提供
*/
taskOption(): TaskOption {
return pick(this.options, ['reactQuery']);
}
}
この構造のおかげで、ConfigLoaderを実装するだけでConfigProviderから使えるようになります。つまり、オプションはいつでも柔軟に追加・変更できます。
2. OpenAPI specificationファイルを受け取る
当初の目標の中に、「ファイルまたはネットワークリクエストでOAS JSONを直接パースし、望む形の結果をファイルとして生成できること。」がありました。これを実現するために、Loaderを実装したFileLoaderとFetchLoaderを作り、inversifyのfactory関数を使って、オプションに応じて使うべきローダーを選択するようにしました。
// inversify.config.ts
const container = new Container();
/**
- 1. IoCコンテナにオブジェクトを登録
*/
container.bind<Loader>(TYPES.Loader).to(FetchLoader).whenTargetNamed('fetch');
container.bind<Loader>(TYPES.Loader).to(FileLoader).whenTargetNamed('file');
/**
- 2. ファクトリー関数の設定
*/
const loaderFactory = container
.bind<interfaces.Factory<Loader>>(TYPES.LoaderFactory)
.toFactory<Loader, ['file' | 'fetch']>((context) => {
return (named) => {
/**
- 3. どのオブジェクトをどのように読み込むかを明示
*/
return context.container.getNamed<Loader>(TYPES.Loader, named);
};
});
// loader.ts
const selectedLoader = container.getNamed(TYPES.LoaderFactory, options.loader);
このように実装すると、既存のコードを修正せずに別のLoaderを追加できるというメリットがあります。例えば、databaseからOAS JSONを読み込む必要がある場合は、次のように対応できます。
class DatabaseLoader implements Loader {
// ...
}
container.bind<Loader>(TYPES.Loader).to(DatabaseLoader).whenTargetNamed('db');
const databaseLoader = container.getNamed(TYPES.LoaderFactory, 'db');
3. Preprocess pipe
Preprocess pipeは、読み込んだOAS JSONをさまざまな条件で加工する工程です。DeprecatedなAPIを除外したり、特定のAPIだけを選択したり、enumの抽出を選択したりといった処理を担当します。preprocessのオプションはいろいろ選べ、こちらもオプションに対応する前処理のロジックが実行時に決まります。PreprocessPipeクラスが、先ほど紹介したfactory関数でPreprocessを実装したクラスを選んで配列に入れ、OAS JSONを順番に渡しながらロジックを実行します。サンプルコードは次のとおりです。
import { flow } from 'lodash';
class PreprocessPipe {
private readonly preprocessOptions: PreprocessConfig;
private readonly initialPreprocess: Preprocess[];
constructor(
@inject(TYPES.PreprocessFactory)
private readonly preprocessFactory: (name: string) => Preprocess,
) {}
exec(swaggerSpecJSON: SwaggerSpecJSON | undefined): SwaggerSpecJSON | undefined {
/**
- 1. オプションで選択されたpreprocessの実装だけを選んで配列に入れる
*/
const preprocess = this.scheduledPreprocess();
const preprocessorFunctions = preprocess.map((preprocess) => preprocess.exec.bind(preprocess));
/**
- 2. preprocessの実装を順番に実行
*/
const result = flow(preprocessorFunctions)(swaggerSpecJSON);
return result;
}
private scheduledPreprocess() {
if (!this.preprocessOptions) {
return [];
}
/**
- enabled = trueのオプションのオブジェクトだけを読み込む
*/
const selectedPreprocess = Object.entries(this.preprocessOptions)
.filter(([, enabled]) => enabled)
.map(([name]) => this.preprocessFactory(name));
return this.initialPreprocess.concat(selectedPreprocess);
}
}
こちらも、オプションを追加・削除しても既存のロジックに影響しないように実装しています。オプションの読み込みと検証、OAS JSONファイルの読み込み、preprocessの実行という前処理の段階は、使う場所によって実行のされ方が変わりうるため、できるだけ柔軟に設計しました。
3. ReactQueryTask
上で説明したとおり、json-schema-to-typescriptというライブラリで望む生成物を得るためにオブジェクトを作る工程です。ある意味で最も重要な部分なので、責務を細かく分けることに気を配りました。基本的な流れは次のとおりです。
Generator役のオブジェクトは、Parser役のオブジェクトに必要な部分のパースを依頼します。Parserが結果を返すと、それぞれの役割に応じて生成物を文字列として組み立てます。
Object Generatingを担当するオブジェクトは、パースされたデータを使って適切な形のオブジェクトを作ります。インターフェースとして生成される名前を指定して、json-schema-to-typescriptに渡します。例えば次のとおりです。
import { compile } from 'json-schema-to-typescript';
const queryString = queryStringParser.generate(oasJson);
/**
- {
- "title": "V1UserListQueryString",
- "type": "object",
- "properties": {
- "firstName": {
- "type": "string"
- },
- "lastName": {
- "type": "string"
- }
- }
- }
*/
const result = await compile(queryString);
/**
- export interface V1UserListQueryString {
- firstName?: string;
- lastName?: string;
- }
*/
react-queryで使うhooksを作るオブジェクトも、同じくパースされたデータから適切な形のhooksの文字列を作ります。Hooksの場合は、template literalを使えば生成結果を簡単に宣言できます。
// template.ts
export default function useMutationTemplate(name: string, method: string) {
const type = ${name}Response;
const optionType = UseMutationOptions<${name}Response, unknown, ${name}Variables, TContext>;
return `export function use${name}Mutation<TContext>({
url,
options,
requestOptions,
}: {
url: string;
options?: ${optionType};
requestOptions?: RequestOptions;
}) {
const result = useMutation<${type}, unknown, ${name}Variables, TContext>(
(variables) => fetcher.${method}(url, variables, requestOptions),
options,
)
return { ...result, data: result.data?.data };
}`;
}
// ReactMutationGenerator.ts
const result = useMutationTemplate('V1UserList', 'POST');
このようにgenerate layerで作られた結果は、それぞれstring[]の形でReactQueryTaskに戻されます。ここまでの説明は長くてピンとこないかもしれませんが、次のダイアグラムを見ていただければ、思ったよりシンプルな構造であることがおわかりいただけると思います。
4. TypeGuardTask
1. Type guardとは何か
TypeScriptにおけるtype guardとは、複数の型を取りうる変数について、型の推論範囲を絞り込む(narrowing)ための関数のことです。例えば次のとおりです。
// type guard
const isString = (value: unknown): value is string => typeof value === 'string';
const numOrStringDivide = (value: string | number) => {
if (isString(value)) {
return parseInt(value, 10) / 2; // value -> string
}
return value / 2; // value -> number
};
フロントエンドでtype guardが必要になるのは、上の例のようにprimitive typeを区別したい場合もありますが、API responseの違いを見分けて処理を分岐させなければならない場面が出てくるからです。特に、通常の関数だけでなくcomponentの中でも便利です。例えば、API responseの形に応じてレンダリングするcomponentを切り替えたい場合です。こうした場面で、Inflabのフロントエンドチームはtype guardを積極的に使っています。
const isStudent = (value: Student | Teacher): value is Student => {
return isPlainObject(value) && 'grade' in value && typeof value.grade === 'string';
};
export function Profile() {
// Student | Teacher
const { data } = useProfileQuery();
return (
<div>{isStudent(data) ? <StudentProfile data={data} /> : <TeacherProfile data={data} />}</div>
);
}
2. Type guardの落とし穴
もちろん、GraphQLを使えば__typenameというプロパティを使って簡単に区別できます。しかし、そうでない場合は一般的に、API responseの形を必要な分だけパースする方法で型を区別することになります。上の例で言えば、ある値がオブジェクトで、'grade'というstring型のプロパティを持っていれば、'Student'型とみなすことになります。
これはTypeScriptの構造的型システム(structural type system)に起因するもので、よくまとまったドキュメントが多いので、ここではこれ以上説明しません。
本題に戻ると、Inflabのフロントエンドチームではtype guardを積極的に使っていますが、メリットと同じくらいデメリットもはっきりしていました。それは、type guard自体が誤って宣言されている場合、ロジック上の誤りが表に出てこないということです。代表的なのが、API responseが変わったのにtype guardを変更し忘れたケースです。
上の'Student'型で言えば、API responseが変更されて'grade'というプロパティの型が変わったり、プロパティ自体がなくなったりするケースが考えられます。こうなると、コードレビューをしても見つけにくいだけでなく、コンパイル時には何のエラーも発生しないため、ランタイムで問題が発覚することが多いです。
3. Type guardを自動で作る
こうした理由から、type guardもOAS JSONから自動で作られたらどうだろう? というアイデアが浮かびました。同僚と議論した結果、jsonschemaを使ってOASのschemaを検証できる形にし、ファイルとして出力する機能を実装することに決めました。実装全体の流れは次のとおりです。
まず、OAS JSONをJSON schemaの形に変換し、JSON schemaがサポートしていないプロパティを取り除きます。
import { draft7 } from 'json-schema-migrate';
import { unset } from 'lodash';
function convertSwaggerSpecToJSONSchema(swaggerSpec: SwaggerSpecJSON) {
const componentSwaggerSpecs = swaggerSpec.components.schemas;
/**
- 1. OAS -> json schemaの形に変換
*/
draft7(
toJsonSchema(swaggerSpec.components.schemas, {
strictMode: true,
}),
);
/**
- 2. json schemaでサポートされていないプロパティを削除
*/
removeUnSupportProperty(componentSwaggerSpecs);
return componentSwaggerSpecs;
}
function removeUnSupportProperty(componentSwaggerSpecs: Record<string, JSONSchema4>) {
const notSupportedType = ['default', 'deprecated', 'nullable', 'tsEnumNames', 'example'];
traverse(componentSwaggerSpecs, ({ parent, key }) => {
key ??= '';
if (notSupportedType.includes(key)) {
unset(parent, key);
}
});
}
こうして作ったJSON schemaを、template関数を使って文字列にすれば完成です。
const defaultTypeGuardTemplate = (jsonSchemas: Record<string, JSONSchema4>) => {
return [
importTemplate(Object.keys(jsonSchemas)).trim(),
'const validator = new Validator();',
Object.entries(jsonSchemas)
.map(([key, schema]) => typeGuardTemplate(key, schema))
.join('')
.trim(),
].join('\n\n');
};
const importTemplate = (keys: string[]) => {
return `
import { Validator } from 'jsonschema';
import type { ${keys.join(', ')} } from './schema';
`;
};
const typeGuardTemplate = (type: string, jsonSchema: JSONSchema4) => {
const stringifyJsonScheme = JSON.stringify(jsonSchema);
return `
export const ${is${type}} = (value: unknown): value is ${type} => {
return validator.validate(value, ${stringifyJsonScheme}).valid;
}
`;
};
const schema = convertSwaggerSpecToJSONSchema(oasJson);
const result = defaultTypeGuardTemplate(schema);
まだ完璧に動作するわけではありませんが、問題なく使えるようドッグフーディングを続けています。
5. SchemaValidatorTask
上でもお伝えしたとおり、Elixirのdoc testの考え方を取り入れ、バックエンドエンジニアが自分の宣言したOASと実際のresponseが一致しているかを、integration testの段階で検証できる機能を提供しようとしました。ライブラリにはAjvを選びました。バックエンド側で別途ライブラリをインストールしなくても、standaloneモードで動作するからです。
SchemaValidatorTaskの仕組みは、前の2つのタスクに比べてとてもシンプルです。OASをJSON schemaに変換し、standaloneモードを適用した生成物を返せば完了です。
import toJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema';
import Ajv from 'ajv';
import standaloneCode from 'ajv/dist/standalone';
const schema = toJsonSchema(oasJson);
const ajv = new Ajv({
schemas,
code: {
source: true,
optimize: true,
},
});
const result = standaloneCode(ajv);
6. 生成物の出力
各タスクでは、受け取ったstring配列から一定の基準で重複を取り除きます。ここまで問題がなければ、オプションで指定されたパスに結果を出力します。
4. 生成物の形
次のようなOAS JSONを受け取ったと仮定すると、各タスクの実行結果は以下のとおりです。
OAS.json{
"openapi": "3.0.0",
"paths": {
"/api/v1/category": {
"get": {
"operationId": "CategoryB2cController_findAll",
"summary": "すべてのカテゴリー一覧",
"description": "すべてのカテゴリー一覧を取得する。",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"properties": {
"statusCode": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CategoryAllResponse"
}
}
}
}
}
}
},
"tags": ["カテゴリーenum API"]
}
}
},
"components": {
"schemas": {
"CodeName": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["code", "name"]
},
"JobGroupCodeName": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
},
"jobs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CodeName"
}
}
},
"required": ["code", "name", "jobs"]
},
"CategoryAllResponse": {
"type": "object",
"properties": {
"jobGroup": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JobGroupCodeName"
}
},
"jobSeekerStatus": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CodeName"
}
}
},
"required": ["jobSeekerStatus"]
}
}
}
}
ReactQueryTaskimport { useQuery, useMutation } from '@tanstack/react-query';
import type { UseQueryOptions, UseMutationOptions, QueryKey } from '@tanstack/react-query';
import fetcher from './Fetcher';
import type { RequestOptions } from './Fetcher';
export type Maybe<T> = T | null;
export type OneOf<T> = T extends unknown[]
? T[number]
: T extends readonly unknown[]
? T[number]
: T;
export type Exact<T extends { [key: string]: unknown }> = {
[K in keyof T]-?: Exclude<T[K], undefined>;
};
export type Values<T extends Record<string, unknown>> = T extends Record<string, infer U>
? U
: T extends { [key: string]: infer U }
? U
: never;
export const enum ResponseStatus {
OK = 'OK',
SERVER_ERROR = 'SERVER_ERROR',
DUPLICATE = 'DUPLICATE',
NOT_FOUND = 'NOT_FOUND',
BAD_REQUEST = 'BAD_REQUEST',
BAD_PARAMETER = 'BAD_PARAMETER',
CONFLICT = 'CONFLICT',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
}
export type Success<T = unknown> = {
statusCode: ResponseStatus.OK;
message: '';
data: T;
};
export type Error = {
statusCode: Omit<keyof typeof ResponseStatus, 'OK'>;
message: string;
data: Maybe<''>;
};
/**
- @typeParam T - Response data type
*/
export type ApiResponse<T = unknown> = Success<T>;
export const API_ENDPOINT = {
V1_CATEGORY: '/api/v1/category',
} as const;
export const fetchV1Category = async (url: string, requestOptions?: RequestOptions) =>
fetcher.get<V1CategoryResponse>(url, requestOptions);
export function useV1CategoryQuery({
queryKey,
url,
options,
requestOptions,
}: {
queryKey: QueryKey;
url: string;
options?: Omit<
UseQueryOptions<V1CategoryResponse, unknown>,
'queryKey' | 'queryFn' | 'initialData'
> & {
initialData?: () => undefined;
};
requestOptions?: RequestOptions;
}) {
const result = useQuery<V1CategoryResponse, unknown>(
queryKey,
() => fetchV1Category(url, requestOptions),
options,
);
return { ...result, data: result.data?.data };
}
/**
- created by ResponseSchemaGenerator
*/
export interface V1Category {
data: Maybe<CategoryAllResponse>;
}
export interface CategoryAllResponse {
jobGroup: Maybe<JobGroupCodeName[]>;
jobSeekerStatus: CodeName[];
}
export interface JobGroupCodeName {
code: string;
name: string;
jobs: CodeName[];
}
export interface CodeName {
code: string;
name: string;
}
export type V1CategoryResponse = ApiResponse<V1Category['data']>;
TypeGuardTaskimport { Validator } from 'jsonschema';
import type { CodeName, JobGroupCodeName, CategoryAllResponse } from './schema';
const validator = new Validator();
export const isCodeName = (value: unknown): value is CodeName => {
return validator.validate(value, {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
}).valid;
};
export const isJobGroupCodeName = (value: unknown): value is JobGroupCodeName => {
return validator.validate(value, {
type: 'object',
properties: {
code: { type: 'string' },
name: { type: 'string' },
jobs: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['code', 'name', 'jobs'],
}).valid;
};
export const isCategoryAllResponse = (value: unknown): value is CategoryAllResponse => {
return validator.validate(value, {
type: 'object',
properties: {
jobGroup: {
type: 'array',
items: {
type: 'object',
properties: {
code: { type: 'string' },
name: { type: 'string' },
jobs: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['code', 'name', 'jobs'],
},
},
jobSeekerStatus: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['jobSeekerStatus'],
}).valid;
};
SchemaValidatorTaskの結果は1つの複雑なファイルになるため、ここでは紹介を割愛します。
こうして作られた生成物は、フロントエンドのコードで次のように使えます。
import { useV1CategoryQuery, API_ENDPOINT } from './schema';
import { isJobGroupCodeName } from './guard';
const retrieveJobGroupCodeNameJobs = (value: unknown) => {
if (isJobGroupCodeName(value)) {
return value.jobs;
}
return [];
};
export function A() {
const { data } = useV1CategoryQuery({
url: API_ENDPOINT.V1_CATEGORY,
queryKey: [API_ENDPOINT.V1_CATEGORY],
});
return <div>{data.jobSeekerStatus.map((status) => status.name)}</div>;
}
バックエンドのテストコードでは、schema validatorを次のように使えます。
import validator from './validator';
describe('categoryAllResponse', () => {
it('categoryAllResponseのOASと実際の結果は一致しなければならない。', async () => {
const response = await controller.categoryAllResponse();
const result = validator.categoryAllResponse(result);
expect(result).toBe(true);
});
});
5. まとめ
最初にお伝えしたとおり、一見すると工程は複雑に見えますが、まったくそんなことはありません。むしろ実装中に悩んだのは、責務をどう適切に分担するかと、SonarQubeのduplicate line code smellを避けるためにコードの重複をどう抑えるかでした。そのため、コード全体を通して次のような選択をしました。
各オブジェクトの責務を細かく分離するよう努めました。
各オブジェクトの横断的関心事はServiceというlayerに分け、必要なオブジェクトが必要な分だけ参照するように実装しました。
実行時に動くロジックを切り替える必要がある場合は、Strategyパターンを積極的に使いました。
同じロジックで小さな部分だけを変える必要がある場合は、Template Methodパターンを使いましたが、継承が1段階を超えないように実装しました。
実装した構造を簡単に表すと、次のようになります。
実装の紹介は以上です。
次回の記事では、code generatorを導入してから働き方がどのように変わったのかをご紹介します。
Migrating a Real TypeScript OSS Library from tsup to tsdown 🔧
> The config migration was small. Preserving the package contract was the interesting part.
Hoi hoi! 👋
I'm @nyaomaru, a frontend engineer who enjoyed looking for fungus this season. 😸🍄
Recently, I migrated its build setup from tsup to tsdown.
At first, I expected this to be a very small task.
Just remove tsup.
Just install tsdown.
Just change the config.
Run the build.
Done! 🎉
And honestly...
The config migration was small.
But there was one interesting problem.
> The first tsdown build succeeded while silently changing files that were already part of the package's public contract.
So this article isn't really an introduction to tsdown.
Instead, I want to show what happened when I migrated a real published TypeScript library from tsup to tsdown, what actually changed, and how I verified that consumers would still receive the same package.
Let's take a look! 👀
---
🤔 Why Migrate a Package That Already Builds Correctly?
is-kit is a zero-dependency TypeScript library for runtime type guards.
Its build setup was already pretty boring.
And boring build systems are good. 😸
The package had:
- one entry point
- ESM and CJS outputs
- bundled declaration files
- explicit package.json exports
- a declaration banner
- a packed-package smoke test
Before the migration, it used
| Environment | Value |
| ------- | -------------------------------- |
| Package | is-kit@1.14.2 |
| Bundler | tsup@8.5.1 |
| Entry | src/index.ts |
| Output | ESM + CJS + bundled declarations |
| Target | esnext |
I wasn't trying to solve a broken build.
The motivation was mostly maintenance.
tsdown is built around Rolldown, has an active ecosystem, and is explicitly designed as a migration path for projects currently using tsup.
So the question wasn't
> Can tsdown build this library?
It was
> Can I move the build setup to tsdown without changing what existing consumers receive?
That's a much more useful question for a published package.
---
📦 The Package Contract I Needed to Preserve
For an application, changing an output filename may not matter much.
For a library, it can be a breaking change.
is-kit already exposes files through explicit package exports.
The important paths were effectively
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}
So I wanted the migration to preserve
dist/index.mjs
dist/index.js
dist/index.d.ts
along with the existing ESM/CJS runtime behavior, declaration compatibility, export set, and declaration banner.
In other words
> The source code was not the contract here. The packed npm package was.
That distinction became important very quickly.
---
🛠️ The Migration Looked Almost Trivial
I replaced tsup with tsdown and replaced
tsup.config.ts
with
tsdown.config.mts
I intentionally used .mts.
The package itself is not "type": "module", and using an ESM-specific config extension avoids having Node reinterpret the config file and emit related warnings.
Most of the important options mapped almost directly.
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
outDir: "dist",
target: "esnext",
banner: {
dts: dtsBanner,
},
});
The declaration banner also stayed dts-only with banner: { dts: dtsBanner }.
So far, everything looked easy.
Then I ran the build.
It passed.
And the package contract was wrong. 🙀
---
💥 The First Build Succeeded and Changed My Filenames
This was the interesting part.
Before the migration, the important generated files looked like this:
| Build | Main generated files |
| -------------------------- | ------------------------------------------------------ |
| tsup | index.mjs, index.js, index.d.ts |
| default tsdown migration | index.mjs, index.cjs, index.d.mts, index.d.cts |
The tsdown build completed successfully with exit code 0.
But my existing package.json still expected
require → ./dist/index.js
types → ./dist/index.d.ts
Those files no longer matched the generated output.
If I had stopped at
pnpm build
and published the package.
CJS consumers and TypeScript resolution could have been broken.
This was the most important lesson from the migration
> Build success ≠ package contract preserved.
A bundler only knows whether it successfully produced its output.
It doesn't automatically mean that output still matches every promise your already-published package makes.
---
🔧 Fixing the Public Contract with outExtensions
The tsdown migration guide explicitly calls out the rename from outExtension to outExtensions.
For is-kit, I used it to preserve the existing filenames
outExtensions: ({ format }) => ({
dts: format === 'cjs' ? '.d.ts' : '.d.mts',
js: format === 'cjs' ? '.js' : '.mjs',
}),
Now the relevant output became
dist/index.mjs
dist/index.js
dist/index.d.mts
dist/index.d.ts
and the existing exports continued to resolve correctly.
I don't really consider the original output behavior a tsdown bug.
tsdown chooses extensions based on the package type and output format to avoid ambiguous module interpretation.
That's reasonable.
But for an existing package, reasonable new defaults are still changes.
If consumers already depend on your filenames, those filenames are part of your compatibility surface.
---
🧪 Verifying the Published Package with a Smoke Test
This is where an existing packed-package smoke test in is-kit helped a lot.
I already had a pnpm test:package command that builds the package, runs npm pack, installs the generated tarball into a temporary project, and verifies it from a real consumer's perspective.
Instead of testing this
src/index.ts
it tests this
is-kit-1.14.2.tgz
↓
temporary consumer
↓
npm install
A library can work perfectly inside its own repository while still publishing a broken package, so this smoke test exercises the artifact that users would actually install.
After the migration, I expanded the test to verify more of the package contract 👇
| Contract | Result |
| ------------------------------------------ | ------------------ |
| exports["."].import → ./dist/index.mjs | ✅ pass |
| exports["."].require → ./dist/index.js | ✅ pass |
| exports["."].types → ./dist/index.d.ts | ✅ pass |
| Runtime dependencies | ✅ 0 |
| ESM exports | ✅ same 83 exports |
| CJS exports | ✅ same 83 exports |
| Declaration banner | ✅ preserved |
| ESM runtime import | ✅ pass |
| CJS runtime require | ✅ pass |
| Packed TypeScript consumer | ✅ pass |
I also installed the packed package into temporary consumer projects using TypeScript v5.7 through v7.0.
All of them successfully resolved and consumed the generated declarations.
This isn't testing whether each TypeScript version can generate declarations through tsdown. It's testing the artifact my users actually install.
So if a future build change accidentally alters an extension, export path, declaration file, or runtime behavior, the smoke test should catch it before publishing.
For a library migration like this, that is much more meaningful than simply asserting "build exited successfully".
---
📏 The Output Actually Got Bigger
I was also curious about artifact size.
This produced a result I didn't expect.
| Metric | tsup | tsdown | Difference |
| ------------------ | --------: | --------: | ---------: |
| JS + dts total | 116,503 B | 144,007 B | +23.6% |
| ESM JavaScript | 15,787 B | 29,410 B | +86.3% |
| CJS JavaScript | 19,318 B | 31,155 B | +61.3% |
| dts, one format | 40,699 B | 41,721 B | +2.5% |
| npm pack tarball | 37,226 B | 42,366 B | +13.8% |
In this configuration, the tsdown output retained more comments and region markers than the previous tsup output.
So the raw JavaScript became noticeably larger.
Compression reduced the difference in the actual npm tarball, but didn't remove it.
For is-kit, this isn't particularly concerning.
It is a small zero-dependency utility library, and we're talking about a few kilobytes in the final tarball.
But it was still a useful reminder
> A faster or newer bundler does not automatically mean a smaller artifact.
If package size is a strict constraint for your library, I would compare the generated files and decide on your minification strategy before migrating.
---
⏱️ What About Build Performance?
I also measured wall-clock build time.
The old tsup build
1.50 s
The new tsdown build across three runs
1.22 s
1.17 s
1.18 s
Looking only at those numbers, it would be very tempting to say
> tsdown made the build faster! 🚀
But I don't think this benchmark supports that conclusion.
I only have one measured tsup run in this comparison.
Also, this is a tiny single-entry library where declaration generation represents a large part of the build.
The bundlers' own timing output was roughly
tsup
JavaScript: ~22 ms
dts: ~707 ms
tsdown
complete: ~717–755 ms
The wall-clock result was better, but with only one tsup sample and declaration generation dominating this tiny library, I don't think this is enough evidence to claim a meaningful performance improvement.
But no, I'm not migrating because I saved around 300 ms.
---
🐱 Was the Migration Actually Small?
For is-kit, yes.
There were no source-code changes.
The migration was basically limited to
dependency
config
task documentation
package smoke assertions
The important options were almost one-to-one.
But I think it's important to explain why it stayed small.
is-kit has
- one entry
- zero runtime dependencies
- no bundler plugins
- no CSS pipeline
- no complicated code splitting
and, most importantly, it already had a way to test the packed package as a consumer.
The migration becomes more interesting if your package relies on custom plugins, unusual entries, CSS processing, exact source maps, generated exports, strict byte-size limits, or older Node build environments.
There is also a build-environment requirement worth checking.
For the version tested here
tsdown 0.23.0
Rolldown 1.2.8
the relevant Node engine requirement is
^22.18.0 || ^24.11.0 || >=26.0.0
My CI uses
Node 22.22.0
so that was fine.
But if contributors still build your library with Node v20 or an older Node v22 version, this is something you need to solve before migrating.
That doesn't necessarily mean your library consumers must use Node v22.
It's a build-tool requirement.
Still, contributor and CI environments are part of the migration cost too. 😿
---
🎯 So, Was Moving from tsup to tsdown Worth It?
For is-kit, I think yes.
But not because of performance.
The migration preserved the package contract across ESM, CJS, declarations, exports, and the packed-package consumer tests.
The configuration change was small and reviewable.
My existing Node environment satisfies the new build requirement.
And the build setup is now aligned with the actively evolving Rolldown ecosystem.
The cost was also real
- artifact size increased
- Node build requirements increased
- output extensions needed explicit configuration
So I wouldn't describe this as
> Everyone using tsup should migrate immediately because tsdown is faster!
That's not what this experiment showed.
For me, the better conclusion is
> If your project already meets the Node requirements and you want to move your build setup toward the Rolldown ecosystem, migrating a small library from tsup to tsdown can be a very reasonable change.
But verify the package you publish.
Not only the source.
Not only the config.
And definitely not only the green build message. 😸
Because the most interesting bug in this migration happened when the build said everything was fine.
---
If you'd like to see the package from this article in a real project, is-kit is open source.
It's a lightweight, zero-dependency TypeScript type guard library focused on runtime validation and safe narrowing.
If it looks useful for your project, I'd be happy if you gave it a try and a ⭐ on GitHub is always appreciated.
{% embed nyaomaru/is-kit %}
Issues and PRs are welcome too. 😸
75 reactions · 31 comments
Mentions
Code generator開発記(Part 2) ! この記事は2023年7月3日に執筆されたものです。 こんにちは。Inflabのフロントエンドエンジニア、Lucasです。 前回の記事では、code generatorの必要性と、自前で実装するに至るまでの悩みを共有しました。今回は、code generatorをはじめ、schema validator、type guardをどのように実装したのかを簡単にご紹介したいと思います。 最初にお伝えしておくと、これはとてもシンプルなアイデアで、実装に複雑な技術は必要ありません。その前提で読み進めていただければと思います。 1. 目標と必要な機能 まず、前回の記事で立てた目標について改めて考えてみます。私はcode generatorを実装する前に、次のような目標を立てました。 ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。 フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。 フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。 APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。 バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。 バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。 特に最後の目標は、Elixirのdoc testの概念を借りたものです。昨年取り組んだ個人プロジェクトでテストを書いた際の感触が良く、GraphQLがサポートしているようなschemaの整合性検証も、近い形なら実現できるのではないかと期待していました。特に私たちのバックエンドチームはテストを書くことを非常に重視しているので、OpenAPI specificationと実際のAPIの結果をテストする手軽な方法を提供できるだろうと考えました。 以下、OpenAPI specification JSONはOASと略します。 2. 使用したライブラリ まず、実装に使ったライブラリを紹介します。おそらくほとんどの方がご存じだと思います。 1. 実装 inversify 機能が増えるほど、コードベースは必然的に大きく複雑になるだろうと考えました。また、複数のオプションをサポートする予定だったので、実行時に動く実装も変わりうると見ていました。 そのため機能間の依存関係が複雑になりそうだと考え、悩んだ末に依存性注入(DI)のためのIoCコンテナを探すことにしました。私はinversifyを選びましたが、もう少し軽量に使いたいのであれば、typediという選択肢もあります。 json-schema-to-typescript 事実上、code generatorの中核となるライブラリです。Code generatorのほぼすべての実装は、このライブラリから望む結果を得るためのオブジェクトを作る作業だと言っても過言ではありません。例えば、次のようなJSON schemaがある場合、 { "title": "Example Schema", "type": "object", "properties": { "firstName": { "type": "string" }, "lastName": { "type": "string" }, "age": { "description": "Age in years", "type": "integer", "minimum": 0 }, "hairColor": { "enum": ["black", "brown", "blue"], "type": "string" } }, "additionalProperties": false, "required": ["firstName", "lastName"] } 次のように型を生成します。 export interface ExampleSchema { firstName: string; lastName: string; /** - Age in years */ age?: number; hairColor?: 'black' | 'brown' | 'blue'; } lodash object-traversal js-yaml オプションをYAML形式で受け取るつもりでインストールしました。今思えば、普通にJavaScriptで受け取っても問題なかったと思います。 jsonschema Code generatorが受け付けられるオプションを検証するために選びました。オプション関連のコードがif ~ elseだらけになるのを避けたかったのです。 2. ビルド、デプロイ TypeScript tsup esbuildベースのビルドツールです。速くて設定が簡単なので選びました。 Release Please ライブラリではありませんが、このGitHub Actionのおかげで手軽にデプロイできたので紹介します。 コミットメッセージの中にconventional commit形式で書かれたものがあれば、pull requestの作成からデプロイまで自動で行ってくれます。特にchange log、release、git tagなども自動で管理してくれるので、開発者はコードだけに集中できます。 ちなみに、Inflabのフロントエンドチームでは、monorepoで構成されたパッケージはchangesetsで、単独のリポジトリで運用しているパッケージはRelease Pleaseで管理しています。 commitlint 上で説明したRelease Pleaseの動作の仕組み上、開発者のミスを防ぐために導入しました。コードの変更がコミットされる前にコミットメッセージを検査し、conventional commitの形式に従っていなければコミットできないようにします。 コミットメッセージの一貫性を保ち、デプロイのためにコードを変更したのに、コミットメッセージの誤りでパッケージがデプロイされないという問題を防ぐのが狙いです。 3. テスト Jest 3. 実装の詳細 Code generatorは、大きく3つの段階に分けて実行されます。1つ目は前処理の段階です。正しいオプションが渡されたかを検証し、OASのJSONファイルを読み込みます。続いて、ユーザーが望む前処理ロジック(Pre process)を実行して、読み込んだOAS JSONを加工します。 2つ目の段階では、Taskと呼ばれる作業単位が並列に実行されます。どのタスクを実行するかは、そのタスクのオプションが正しく入力されているかどうかによって決まります。 ReactQueryTaskでは、前処理済みのOAS JSONから生成物に使うオブジェクトを作り、適切な名前と対応付けます。Query、url params、response、request bodyなど、必要と思われる要素をすべて抽出し、型とhooksに変換します。 TypeGuardTaskでは、ReactQueryTaskで作られたインターフェースを検証するtype guard関数を作ります。 SchemaValidatorTaskは、OAS JSONで宣言されたすべてのschemaを検証できるstandaloneのAjvインスタンスを生成します。Ajvについては、後ほど改めて詳しく紹介します。 Taskの並列実行が終わったら、最後の段階として、指定された場所へファイルを書き出します。フローチャートで表すと、次のようになります。 1. オプションの入力を受け取る ユーザーから希望するオプションをYAML形式で受け取るように構成しました。そのオプションは次のとおりです。 loader: fetch | file [required] schema: string [required] output-dir: string [required] react-query: [optional] output: string [required] fetcher: string [required] version: 3 | 4 [optional] default: 3 validator: [optional] output: string [required] typeguard: [optional] output: string [required] preprocess: [optional] filterDeprecated: boolean [optional] default: false extractEnum: boolean [optional] default: false apiPaths: - path: string [required] exact: boolean [optional] tags: - string [required] 2. 前処理 1. オプションの検証 YAMLファイルの有無の確認、node argumentsの入力チェック、kebab-caseのオプションのcamelCaseへの変換など、I/Oと副作用はConfigProviderが担当するようにしました。 一方、react-query、preprocessなど各オプションのパース、検証、値の設定は、ConfigLoaderを実装したオブジェクトが担います。各オブジェクトがパースしたオプションは、ConfigProviderに渡されます。こうしたのは、オプションごとの変更の影響を互いに切り離すためです。 例えば、react queryを担当するクラスは次のように実装されています。 import Ajv from 'ajv'; import type ConfigLoader from './ConfigLoader'; import type { JSONSchemaType } from 'ajv'; import { isNil } from 'lodash'; import { InvalidConfigException } from '../utils/exception'; type ReactQueryConfigOptions = { output: string; fetcher: string; version?: number; removeApiEndpointPrefix?: boolean; }; type YamlReactQueryConfig = { reactQuery?: ReactQueryConfigOptions; }; /** - 1. react-queryオプションのjson validationロジック */ const schema: JSONSchemaType<YamlReactQueryConfig> = { type: 'object', properties: { reactQuery: { type: 'object', nullable: true, properties: { output: { type: 'string' }, fetcher: { type: 'string' }, version: { type: 'number', nullable: true, default: 4, minimum: 3, maximum: 4 }, removeApiEndpointPrefix: { type: 'boolean', nullable: true }, }, required: ['output', 'fetcher'], }, }, additionalProperties: true, }; const validate = new Ajv({ useDefaults: true }).compile(schema); export type ReactQueryConfig = ReactQueryConfigOptions | false; export default class ReactQueryConfigLoader implements ConfigLoader<ReactQueryConfig> { /** - 2. react-queryのオプションの検証 */ load(optionCandidate: unknown) { if (!validate(optionCandidate)) { throw new InvalidConfigException('ReactQueryConfigLoader: not a react query config'); } /** - 3. react-queryのオプションを返す - 戻り値の型はReactQueryConfigOptions | false */ if (isNil(optionCandidate.reactQuery)) { return false; } return optionCandidate.reactQuery; } } ReactQueryConfigLoaderでパースしたオプションは、ConfigProviderのパブリックメソッドとして提供されます。 export class ConfigProviderImpl implements ConfigProvider { private loader: Loader; private options: Options; constructor() { /** - 1. yamlファイルの読み込み */ const yamlCandidate = this.loadConfig(); const reactQueryConfigLoader = new ReactQueryConfigLoader(); /** - 2. react-queryのオプションのパースを依頼 */ const reactQueryConfig = reactQueryConfigLoader.load(yamlCandidate); this.options = { reactQuery: reactQueryConfig, }; } private loadConfig() { const flags = args.parse(process.argv); const yamlPath = resolve(process.cwd(), flags.config); if (typeof yamlPath !== 'string') { throw new InvalidPathException('ConfigProvider: failed to open yaml config file'); } const yamlConfig = camelizeKeys(yaml.load(readFileSync(resolve(__dirname, yamlPath), 'utf-8'))); return yamlConfig; } getOptions(): Options { return this.options; } /** - 3. オプションが必要なオブジェクトにオプションを提供 */ taskOption(): TaskOption { return pick(this.options, ['reactQuery']); } } この構造のおかげで、ConfigLoaderを実装するだけでConfigProviderから使えるようになります。つまり、オプションはいつでも柔軟に追加・変更できます。 2. OpenAPI specificationファイルを受け取る 当初の目標の中に、「ファイルまたはネットワークリクエストでOAS JSONを直接パースし、望む形の結果をファイルとして生成できること。」がありました。これを実現するために、Loaderを実装したFileLoaderとFetchLoaderを作り、inversifyのfactory関数を使って、オプションに応じて使うべきローダーを選択するようにしました。 // inversify.config.ts const container = new Container(); /** - 1. IoCコンテナにオブジェクトを登録 */ container.bind<Loader>(TYPES.Loader).to(FetchLoader).whenTargetNamed('fetch'); container.bind<Loader>(TYPES.Loader).to(FileLoader).whenTargetNamed('file'); /** - 2. ファクトリー関数の設定 */ const loaderFactory = container .bind<interfaces.Factory<Loader>>(TYPES.LoaderFactory) .toFactory<Loader, ['file' | 'fetch']>((context) => { return (named) => { /** - 3. どのオブジェクトをどのように読み込むかを明示 */ return context.container.getNamed<Loader>(TYPES.Loader, named); }; }); // loader.ts const selectedLoader = container.getNamed(TYPES.LoaderFactory, options.loader); このように実装すると、既存のコードを修正せずに別のLoaderを追加できるというメリットがあります。例えば、databaseからOAS JSONを読み込む必要がある場合は、次のように対応できます。 class DatabaseLoader implements Loader { // ... } container.bind<Loader>(TYPES.Loader).to(DatabaseLoader).whenTargetNamed('db'); const databaseLoader = container.getNamed(TYPES.LoaderFactory, 'db'); 3. Preprocess pipe Preprocess pipeは、読み込んだOAS JSONをさまざまな条件で加工する工程です。DeprecatedなAPIを除外したり、特定のAPIだけを選択したり、enumの抽出を選択したりといった処理を担当します。preprocessのオプションはいろいろ選べ、こちらもオプションに対応する前処理のロジックが実行時に決まります。PreprocessPipeクラスが、先ほど紹介したfactory関数でPreprocessを実装したクラスを選んで配列に入れ、OAS JSONを順番に渡しながらロジックを実行します。サンプルコードは次のとおりです。 import { flow } from 'lodash'; class PreprocessPipe { private readonly preprocessOptions: PreprocessConfig; private readonly initialPreprocess: Preprocess[]; constructor( @inject(TYPES.PreprocessFactory) private readonly preprocessFactory: (name: string) => Preprocess, ) {} exec(swaggerSpecJSON: SwaggerSpecJSON | undefined): SwaggerSpecJSON | undefined { /** - 1. オプションで選択されたpreprocessの実装だけを選んで配列に入れる */ const preprocess = this.scheduledPreprocess(); const preprocessorFunctions = preprocess.map((preprocess) => preprocess.exec.bind(preprocess)); /** - 2. preprocessの実装を順番に実行 */ const result = flow(preprocessorFunctions)(swaggerSpecJSON); return result; } private scheduledPreprocess() { if (!this.preprocessOptions) { return []; } /** - enabled = trueのオプションのオブジェクトだけを読み込む */ const selectedPreprocess = Object.entries(this.preprocessOptions) .filter(([, enabled]) => enabled) .map(([name]) => this.preprocessFactory(name)); return this.initialPreprocess.concat(selectedPreprocess); } } こちらも、オプションを追加・削除しても既存のロジックに影響しないように実装しています。オプションの読み込みと検証、OAS JSONファイルの読み込み、preprocessの実行という前処理の段階は、使う場所によって実行のされ方が変わりうるため、できるだけ柔軟に設計しました。 3. ReactQueryTask 上で説明したとおり、json-schema-to-typescriptというライブラリで望む生成物を得るためにオブジェクトを作る工程です。ある意味で最も重要な部分なので、責務を細かく分けることに気を配りました。基本的な流れは次のとおりです。 Generator役のオブジェクトは、Parser役のオブジェクトに必要な部分のパースを依頼します。Parserが結果を返すと、それぞれの役割に応じて生成物を文字列として組み立てます。 Object Generatingを担当するオブジェクトは、パースされたデータを使って適切な形のオブジェクトを作ります。インターフェースとして生成される名前を指定して、json-schema-to-typescriptに渡します。例えば次のとおりです。 import { compile } from 'json-schema-to-typescript'; const queryString = queryStringParser.generate(oasJson); /** - { - "title": "V1UserListQueryString", - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - } - } - } */ const result = await compile(queryString); /** - export interface V1UserListQueryString { - firstName?: string; - lastName?: string; - } */ react-queryで使うhooksを作るオブジェクトも、同じくパースされたデータから適切な形のhooksの文字列を作ります。Hooksの場合は、template literalを使えば生成結果を簡単に宣言できます。 // template.ts export default function useMutationTemplate(name: string, method: string) { const type = ${name}Response; const optionType = UseMutationOptions<${name}Response, unknown, ${name}Variables, TContext>; return `export function use${name}Mutation<TContext>({ url, options, requestOptions, }: { url: string; options?: ${optionType}; requestOptions?: RequestOptions; }) { const result = useMutation<${type}, unknown, ${name}Variables, TContext>( (variables) => fetcher.${method}(url, variables, requestOptions), options, ) return { ...result, data: result.data?.data }; }`; } // ReactMutationGenerator.ts const result = useMutationTemplate('V1UserList', 'POST'); このようにgenerate layerで作られた結果は、それぞれstring[]の形でReactQueryTaskに戻されます。ここまでの説明は長くてピンとこないかもしれませんが、次のダイアグラムを見ていただければ、思ったよりシンプルな構造であることがおわかりいただけると思います。 4. TypeGuardTask 1. Type guardとは何か TypeScriptにおけるtype guardとは、複数の型を取りうる変数について、型の推論範囲を絞り込む(narrowing)ための関数のことです。例えば次のとおりです。 // type guard const isString = (value: unknown): value is string => typeof value === 'string'; const numOrStringDivide = (value: string | number) => { if (isString(value)) { return parseInt(value, 10) / 2; // value -> string } return value / 2; // value -> number }; フロントエンドでtype guardが必要になるのは、上の例のようにprimitive typeを区別したい場合もありますが、API responseの違いを見分けて処理を分岐させなければならない場面が出てくるからです。特に、通常の関数だけでなくcomponentの中でも便利です。例えば、API responseの形に応じてレンダリングするcomponentを切り替えたい場合です。こうした場面で、Inflabのフロントエンドチームはtype guardを積極的に使っています。 const isStudent = (value: Student | Teacher): value is Student => { return isPlainObject(value) && 'grade' in value && typeof value.grade === 'string'; }; export function Profile() { // Student | Teacher const { data } = useProfileQuery(); return ( <div>{isStudent(data) ? <StudentProfile data={data} /> : <TeacherProfile data={data} />}</div> ); } 2. Type guardの落とし穴 もちろん、GraphQLを使えば__typenameというプロパティを使って簡単に区別できます。しかし、そうでない場合は一般的に、API responseの形を必要な分だけパースする方法で型を区別することになります。上の例で言えば、ある値がオブジェクトで、'grade'というstring型のプロパティを持っていれば、'Student'型とみなすことになります。 これはTypeScriptの構造的型システム(structural type system)に起因するもので、よくまとまったドキュメントが多いので、ここではこれ以上説明しません。 本題に戻ると、Inflabのフロントエンドチームではtype guardを積極的に使っていますが、メリットと同じくらいデメリットもはっきりしていました。それは、type guard自体が誤って宣言されている場合、ロジック上の誤りが表に出てこないということです。代表的なのが、API responseが変わったのにtype guardを変更し忘れたケースです。 上の'Student'型で言えば、API responseが変更されて'grade'というプロパティの型が変わったり、プロパティ自体がなくなったりするケースが考えられます。こうなると、コードレビューをしても見つけにくいだけでなく、コンパイル時には何のエラーも発生しないため、ランタイムで問題が発覚することが多いです。 3. Type guardを自動で作る こうした理由から、type guardもOAS JSONから自動で作られたらどうだろう? というアイデアが浮かびました。同僚と議論した結果、jsonschemaを使ってOASのschemaを検証できる形にし、ファイルとして出力する機能を実装することに決めました。実装全体の流れは次のとおりです。 まず、OAS JSONをJSON schemaの形に変換し、JSON schemaがサポートしていないプロパティを取り除きます。 import { draft7 } from 'json-schema-migrate'; import { unset } from 'lodash'; function convertSwaggerSpecToJSONSchema(swaggerSpec: SwaggerSpecJSON) { const componentSwaggerSpecs = swaggerSpec.components.schemas; /** - 1. OAS -> json schemaの形に変換 */ draft7( toJsonSchema(swaggerSpec.components.schemas, { strictMode: true, }), ); /** - 2. json schemaでサポートされていないプロパティを削除 */ removeUnSupportProperty(componentSwaggerSpecs); return componentSwaggerSpecs; } function removeUnSupportProperty(componentSwaggerSpecs: Record<string, JSONSchema4>) { const notSupportedType = ['default', 'deprecated', 'nullable', 'tsEnumNames', 'example']; traverse(componentSwaggerSpecs, ({ parent, key }) => { key ??= ''; if (notSupportedType.includes(key)) { unset(parent, key); } }); } こうして作ったJSON schemaを、template関数を使って文字列にすれば完成です。 const defaultTypeGuardTemplate = (jsonSchemas: Record<string, JSONSchema4>) => { return [ importTemplate(Object.keys(jsonSchemas)).trim(), 'const validator = new Validator();', Object.entries(jsonSchemas) .map(([key, schema]) => typeGuardTemplate(key, schema)) .join('') .trim(), ].join('\n\n'); }; const importTemplate = (keys: string[]) => { return ` import { Validator } from 'jsonschema'; import type { ${keys.join(', ')} } from './schema'; `; }; const typeGuardTemplate = (type: string, jsonSchema: JSONSchema4) => { const stringifyJsonScheme = JSON.stringify(jsonSchema); return ` export const ${is${type}} = (value: unknown): value is ${type} => { return validator.validate(value, ${stringifyJsonScheme}).valid; } `; }; const schema = convertSwaggerSpecToJSONSchema(oasJson); const result = defaultTypeGuardTemplate(schema); まだ完璧に動作するわけではありませんが、問題なく使えるようドッグフーディングを続けています。 5. SchemaValidatorTask 上でもお伝えしたとおり、Elixirのdoc testの考え方を取り入れ、バックエンドエンジニアが自分の宣言したOASと実際のresponseが一致しているかを、integration testの段階で検証できる機能を提供しようとしました。ライブラリにはAjvを選びました。バックエンド側で別途ライブラリをインストールしなくても、standaloneモードで動作するからです。 SchemaValidatorTaskの仕組みは、前の2つのタスクに比べてとてもシンプルです。OASをJSON schemaに変換し、standaloneモードを適用した生成物を返せば完了です。 import toJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema'; import Ajv from 'ajv'; import standaloneCode from 'ajv/dist/standalone'; const schema = toJsonSchema(oasJson); const ajv = new Ajv({ schemas, code: { source: true, optimize: true, }, }); const result = standaloneCode(ajv); 6. 生成物の出力 各タスクでは、受け取ったstring配列から一定の基準で重複を取り除きます。ここまで問題がなければ、オプションで指定されたパスに結果を出力します。 4. 生成物の形 次のようなOAS JSONを受け取ったと仮定すると、各タスクの実行結果は以下のとおりです。 OAS.json{ "openapi": "3.0.0", "paths": { "/api/v1/category": { "get": { "operationId": "CategoryB2cController_findAll", "summary": "すべてのカテゴリー一覧", "description": "すべてのカテゴリー一覧を取得する。", "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { "properties": { "statusCode": { "type": "string" }, "message": { "type": "string" }, "data": { "$ref": "#/components/schemas/CategoryAllResponse" } } } } } } }, "tags": ["カテゴリーenum API"] } } }, "components": { "schemas": { "CodeName": { "type": "object", "properties": { "code": { "type": "string" }, "name": { "type": "string" } }, "required": ["code", "name"] }, "JobGroupCodeName": { "type": "object", "properties": { "code": { "type": "string" }, "name": { "type": "string" }, "jobs": { "type": "array", "items": { "$ref": "#/components/schemas/CodeName" } } }, "required": ["code", "name", "jobs"] }, "CategoryAllResponse": { "type": "object", "properties": { "jobGroup": { "type": "array", "items": { "$ref": "#/components/schemas/JobGroupCodeName" } }, "jobSeekerStatus": { "type": "array", "items": { "$ref": "#/components/schemas/CodeName" } } }, "required": ["jobSeekerStatus"] } } } } ReactQueryTaskimport { useQuery, useMutation } from '@tanstack/react-query'; import type { UseQueryOptions, UseMutationOptions, QueryKey } from '@tanstack/react-query'; import fetcher from './Fetcher'; import type { RequestOptions } from './Fetcher'; export type Maybe<T> = T | null; export type OneOf<T> = T extends unknown[] ? T[number] : T extends readonly unknown[] ? T[number] : T; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]-?: Exclude<T[K], undefined>; }; export type Values<T extends Record<string, unknown>> = T extends Record<string, infer U> ? U : T extends { [key: string]: infer U } ? U : never; export const enum ResponseStatus { OK = 'OK', SERVER_ERROR = 'SERVER_ERROR', DUPLICATE = 'DUPLICATE', NOT_FOUND = 'NOT_FOUND', BAD_REQUEST = 'BAD_REQUEST', BAD_PARAMETER = 'BAD_PARAMETER', CONFLICT = 'CONFLICT', UNAUTHORIZED = 'UNAUTHORIZED', FORBIDDEN = 'FORBIDDEN', PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE', TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', } export type Success<T = unknown> = { statusCode: ResponseStatus.OK; message: ''; data: T; }; export type Error = { statusCode: Omit<keyof typeof ResponseStatus, 'OK'>; message: string; data: Maybe<''>; }; /** - @typeParam T - Response data type */ export type ApiResponse<T = unknown> = Success<T>; export const API_ENDPOINT = { V1_CATEGORY: '/api/v1/category', } as const; export const fetchV1Category = async (url: string, requestOptions?: RequestOptions) => fetcher.get<V1CategoryResponse>(url, requestOptions); export function useV1CategoryQuery({ queryKey, url, options, requestOptions, }: { queryKey: QueryKey; url: string; options?: Omit< UseQueryOptions<V1CategoryResponse, unknown>, 'queryKey' | 'queryFn' | 'initialData' > & { initialData?: () => undefined; }; requestOptions?: RequestOptions; }) { const result = useQuery<V1CategoryResponse, unknown>( queryKey, () => fetchV1Category(url, requestOptions), options, ); return { ...result, data: result.data?.data }; } /** - created by ResponseSchemaGenerator */ export interface V1Category { data: Maybe<CategoryAllResponse>; } export interface CategoryAllResponse { jobGroup: Maybe<JobGroupCodeName[]>; jobSeekerStatus: CodeName[]; } export interface JobGroupCodeName { code: string; name: string; jobs: CodeName[]; } export interface CodeName { code: string; name: string; } export type V1CategoryResponse = ApiResponse<V1Category['data']>; TypeGuardTaskimport { Validator } from 'jsonschema'; import type { CodeName, JobGroupCodeName, CategoryAllResponse } from './schema'; const validator = new Validator(); export const isCodeName = (value: unknown): value is CodeName => { return validator.validate(value, { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }).valid; }; export const isJobGroupCodeName = (value: unknown): value is JobGroupCodeName => { return validator.validate(value, { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' }, jobs: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['code', 'name', 'jobs'], }).valid; }; export const isCategoryAllResponse = (value: unknown): value is CategoryAllResponse => { return validator.validate(value, { type: 'object', properties: { jobGroup: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' }, jobs: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['code', 'name', 'jobs'], }, }, jobSeekerStatus: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['jobSeekerStatus'], }).valid; }; SchemaValidatorTaskの結果は1つの複雑なファイルになるため、ここでは紹介を割愛します。 こうして作られた生成物は、フロントエンドのコードで次のように使えます。 import { useV1CategoryQuery, API_ENDPOINT } from './schema'; import { isJobGroupCodeName } from './guard'; const retrieveJobGroupCodeNameJobs = (value: unknown) => { if (isJobGroupCodeName(value)) { return value.jobs; } return []; }; export function A() { const { data } = useV1CategoryQuery({ url: API_ENDPOINT.V1_CATEGORY, queryKey: [API_ENDPOINT.V1_CATEGORY], }); return <div>{data.jobSeekerStatus.map((status) => status.name)}</div>; } バックエンドのテストコードでは、schema validatorを次のように使えます。 import validator from './validator'; describe('categoryAllResponse', () => { it('categoryAllResponseのOASと実際の結果は一致しなければならない。', async () => { const response = await controller.categoryAllResponse(); const result = validator.categoryAllResponse(result); expect(result).toBe(true); }); }); 5. まとめ 最初にお伝えしたとおり、一見すると工程は複雑に見えますが、まったくそんなことはありません。むしろ実装中に悩んだのは、責務をどう適切に分担するかと、SonarQubeのduplicate line code smellを避けるためにコードの重複をどう抑えるかでした。そのため、コード全体を通して次のような選択をしました。 各オブジェクトの責務を細かく分離するよう努めました。 各オブジェクトの横断的関心事はServiceというlayerに分け、必要なオブジェクトが必要な分だけ参照するように実装しました。 実行時に動くロジックを切り替える必要がある場合は、Strategyパターンを積極的に使いました。 同じロジックで小さな部分だけを変える必要がある場合は、Template Methodパターンを使いましたが、継承が1段階を超えないように実装しました。 実装した構造を簡単に表すと、次のようになります。 実装の紹介は以上です。 次回の記事では、code generatorを導入してから働き方がどのように変わったのかをご紹介します。
Migrating a Real TypeScript OSS Library from tsup to tsdown 🔧 > The config migration was small. Preserving the package contract was the interesting part. Hoi hoi! 👋 I'm @nyaomaru, a frontend engineer who enjoyed looking for fungus this season. 😸🍄 Recently, I migrated its build setup from tsup to tsdown. At first, I expected this to be a very small task. Just remove tsup. Just install tsdown. Just change the config. Run the build. Done! 🎉 And honestly... The config migration was small. But there was one interesting problem. > The first tsdown build succeeded while silently changing files that were already part of the package's public contract. So this article isn't really an introduction to tsdown. Instead, I want to show what happened when I migrated a real published TypeScript library from tsup to tsdown, what actually changed, and how I verified that consumers would still receive the same package. Let's take a look! 👀 --- 🤔 Why Migrate a Package That Already Builds Correctly? is-kit is a zero-dependency TypeScript library for runtime type guards. Its build setup was already pretty boring. And boring build systems are good. 😸 The package had: - one entry point - ESM and CJS outputs - bundled declaration files - explicit package.json exports - a declaration banner - a packed-package smoke test Before the migration, it used | Environment | Value | | ------- | -------------------------------- | | Package | is-kit@1.14.2 | | Bundler | tsup@8.5.1 | | Entry | src/index.ts | | Output | ESM + CJS + bundled declarations | | Target | esnext | I wasn't trying to solve a broken build. The motivation was mostly maintenance. tsdown is built around Rolldown, has an active ecosystem, and is explicitly designed as a migration path for projects currently using tsup. So the question wasn't > Can tsdown build this library? It was > Can I move the build setup to tsdown without changing what existing consumers receive? That's a much more useful question for a published package. --- 📦 The Package Contract I Needed to Preserve For an application, changing an output filename may not matter much. For a library, it can be a breaking change. is-kit already exposes files through explicit package exports. The important paths were effectively { "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } } } So I wanted the migration to preserve dist/index.mjs dist/index.js dist/index.d.ts along with the existing ESM/CJS runtime behavior, declaration compatibility, export set, and declaration banner. In other words > The source code was not the contract here. The packed npm package was. That distinction became important very quickly. --- 🛠️ The Migration Looked Almost Trivial I replaced tsup with tsdown and replaced tsup.config.ts with tsdown.config.mts I intentionally used .mts. The package itself is not "type": "module", and using an ESM-specific config extension avoids having Node reinterpret the config file and emit related warnings. Most of the important options mapped almost directly. import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/index.ts"], format: ["esm", "cjs"], dts: true, clean: true, outDir: "dist", target: "esnext", banner: { dts: dtsBanner, }, }); The declaration banner also stayed dts-only with banner: { dts: dtsBanner }. So far, everything looked easy. Then I ran the build. It passed. And the package contract was wrong. 🙀 --- 💥 The First Build Succeeded and Changed My Filenames This was the interesting part. Before the migration, the important generated files looked like this: | Build | Main generated files | | -------------------------- | ------------------------------------------------------ | | tsup | index.mjs, index.js, index.d.ts | | default tsdown migration | index.mjs, index.cjs, index.d.mts, index.d.cts | The tsdown build completed successfully with exit code 0. But my existing package.json still expected require → ./dist/index.js types → ./dist/index.d.ts Those files no longer matched the generated output. If I had stopped at pnpm build and published the package. CJS consumers and TypeScript resolution could have been broken. This was the most important lesson from the migration > Build success ≠ package contract preserved. A bundler only knows whether it successfully produced its output. It doesn't automatically mean that output still matches every promise your already-published package makes. --- 🔧 Fixing the Public Contract with outExtensions The tsdown migration guide explicitly calls out the rename from outExtension to outExtensions. For is-kit, I used it to preserve the existing filenames outExtensions: ({ format }) => ({ dts: format === 'cjs' ? '.d.ts' : '.d.mts', js: format === 'cjs' ? '.js' : '.mjs', }), Now the relevant output became dist/index.mjs dist/index.js dist/index.d.mts dist/index.d.ts and the existing exports continued to resolve correctly. I don't really consider the original output behavior a tsdown bug. tsdown chooses extensions based on the package type and output format to avoid ambiguous module interpretation. That's reasonable. But for an existing package, reasonable new defaults are still changes. If consumers already depend on your filenames, those filenames are part of your compatibility surface. --- 🧪 Verifying the Published Package with a Smoke Test This is where an existing packed-package smoke test in is-kit helped a lot. I already had a pnpm test:package command that builds the package, runs npm pack, installs the generated tarball into a temporary project, and verifies it from a real consumer's perspective. Instead of testing this src/index.ts it tests this is-kit-1.14.2.tgz ↓ temporary consumer ↓ npm install A library can work perfectly inside its own repository while still publishing a broken package, so this smoke test exercises the artifact that users would actually install. After the migration, I expanded the test to verify more of the package contract 👇 | Contract | Result | | ------------------------------------------ | ------------------ | | exports["."].import → ./dist/index.mjs | ✅ pass | | exports["."].require → ./dist/index.js | ✅ pass | | exports["."].types → ./dist/index.d.ts | ✅ pass | | Runtime dependencies | ✅ 0 | | ESM exports | ✅ same 83 exports | | CJS exports | ✅ same 83 exports | | Declaration banner | ✅ preserved | | ESM runtime import | ✅ pass | | CJS runtime require | ✅ pass | | Packed TypeScript consumer | ✅ pass | I also installed the packed package into temporary consumer projects using TypeScript v5.7 through v7.0. All of them successfully resolved and consumed the generated declarations. This isn't testing whether each TypeScript version can generate declarations through tsdown. It's testing the artifact my users actually install. So if a future build change accidentally alters an extension, export path, declaration file, or runtime behavior, the smoke test should catch it before publishing. For a library migration like this, that is much more meaningful than simply asserting "build exited successfully". --- 📏 The Output Actually Got Bigger I was also curious about artifact size. This produced a result I didn't expect. | Metric | tsup | tsdown | Difference | | ------------------ | --------: | --------: | ---------: | | JS + dts total | 116,503 B | 144,007 B | +23.6% | | ESM JavaScript | 15,787 B | 29,410 B | +86.3% | | CJS JavaScript | 19,318 B | 31,155 B | +61.3% | | dts, one format | 40,699 B | 41,721 B | +2.5% | | npm pack tarball | 37,226 B | 42,366 B | +13.8% | In this configuration, the tsdown output retained more comments and region markers than the previous tsup output. So the raw JavaScript became noticeably larger. Compression reduced the difference in the actual npm tarball, but didn't remove it. For is-kit, this isn't particularly concerning. It is a small zero-dependency utility library, and we're talking about a few kilobytes in the final tarball. But it was still a useful reminder > A faster or newer bundler does not automatically mean a smaller artifact. If package size is a strict constraint for your library, I would compare the generated files and decide on your minification strategy before migrating. --- ⏱️ What About Build Performance? I also measured wall-clock build time. The old tsup build 1.50 s The new tsdown build across three runs 1.22 s 1.17 s 1.18 s Looking only at those numbers, it would be very tempting to say > tsdown made the build faster! 🚀 But I don't think this benchmark supports that conclusion. I only have one measured tsup run in this comparison. Also, this is a tiny single-entry library where declaration generation represents a large part of the build. The bundlers' own timing output was roughly tsup JavaScript: ~22 ms dts: ~707 ms tsdown complete: ~717–755 ms The wall-clock result was better, but with only one tsup sample and declaration generation dominating this tiny library, I don't think this is enough evidence to claim a meaningful performance improvement. But no, I'm not migrating because I saved around 300 ms. --- 🐱 Was the Migration Actually Small? For is-kit, yes. There were no source-code changes. The migration was basically limited to dependency config task documentation package smoke assertions The important options were almost one-to-one. But I think it's important to explain why it stayed small. is-kit has - one entry - zero runtime dependencies - no bundler plugins - no CSS pipeline - no complicated code splitting and, most importantly, it already had a way to test the packed package as a consumer. The migration becomes more interesting if your package relies on custom plugins, unusual entries, CSS processing, exact source maps, generated exports, strict byte-size limits, or older Node build environments. There is also a build-environment requirement worth checking. For the version tested here tsdown 0.23.0 Rolldown 1.2.8 the relevant Node engine requirement is ^22.18.0 || ^24.11.0 || >=26.0.0 My CI uses Node 22.22.0 so that was fine. But if contributors still build your library with Node v20 or an older Node v22 version, this is something you need to solve before migrating. That doesn't necessarily mean your library consumers must use Node v22. It's a build-tool requirement. Still, contributor and CI environments are part of the migration cost too. 😿 --- 🎯 So, Was Moving from tsup to tsdown Worth It? For is-kit, I think yes. But not because of performance. The migration preserved the package contract across ESM, CJS, declarations, exports, and the packed-package consumer tests. The configuration change was small and reviewable. My existing Node environment satisfies the new build requirement. And the build setup is now aligned with the actively evolving Rolldown ecosystem. The cost was also real - artifact size increased - Node build requirements increased - output extensions needed explicit configuration So I wouldn't describe this as > Everyone using tsup should migrate immediately because tsdown is faster! That's not what this experiment showed. For me, the better conclusion is > If your project already meets the Node requirements and you want to move your build setup toward the Rolldown ecosystem, migrating a small library from tsup to tsdown can be a very reasonable change. But verify the package you publish. Not only the source. Not only the config. And definitely not only the green build message. 😸 Because the most interesting bug in this migration happened when the build said everything was fine. --- If you'd like to see the package from this article in a real project, is-kit is open source. It's a lightweight, zero-dependency TypeScript type guard library focused on runtime validation and safe narrowing. If it looks useful for your project, I'd be happy if you gave it a try and a ⭐ on GitHub is always appreciated. {% embed nyaomaru/is-kit %} Issues and PRs are welcome too. 😸