mizdra's blog

ぽよぐらみんぐ

tsx と Node.js Type Stripping の違い

tsx は TypeScript コードを事前トランスパイルすることなく、直接 Node.js で実行するためのツール。

ところで最近の Node.js には Type Stripping という機能が入った。これを使うと、tsx なしで TypeScript コードを事前トランスパイルせずに実行できる。

両者の違い

一見すると両者は機能的に同じものかのように思うけど、実は結構違いがある。

import specifier の指定方法が異なる

最も大きな違いは、「import specifier」の指定方法。import specifier というのは、以下の部分のこと。

import { add } './math';
//             ^^^^^^^^ こことか

const { sleep } = await import('./util');
//                             ^^^^^^^^ ここのこと

tsx は import specifier の様々な指定方法に対応しているが、Node.js Type Stripping はかなり限られている。math.ts というモジュールを参照する場合を例にすると...

指定方法 tsx Node.js Type Stripping
'./math' OK NG
'./math.ts' OK OK
'./math.js' OK NG

tsx は bundler がサポートしているような指定方法を同じようにサポートしてて、多くの人が慣れ親しんだ挙動になってる。一方 Node.js Type Stripping は .ts の指定が必須で、クセが強い。

何故このような仕様になってるかというと、1つは実行時のオーバーヘッドを減らすため。というのも、Node.js の ESM には import speficier の拡張子を明示しなければならないという制約がある。

A file extension must be provided when using the import keyword to resolve relative or absolute specifiers.

https://nodejs.org/api/esm.html#mandatory-file-extensions

拡張子が明示されていれば、CJS or ESM をすぐに判定できる。しかし省略されてると、推測するためのオーバーヘッドが掛かる。それを嫌って、拡張子の明示が強制されている。Type Stripping でも同じで、拡張子が明示されていれば CJS or ESM をすぐ判定できる。それに JavaScript or TypeScript どちらとして処理すれば良いかも、すぐ判定できる。そのために Type Stripping で拡張子の明示を必須としたい、という動機があるらしい。

他にも色々理由があるらしい。拡張子の省略を許可すると require() に破壊的変更を加えることになってしまうから避けたいとか、math.tsmath.js が共存してる時どっちを読むのか曖昧で良くないとか。以下の issue に書いてある。

以下の関連する issue にも様々な理由が書かれていそうだった。けどコメントが多すぎて僕には追いきれなかった...。

tsx は JSX 対応してるが、Node.js Type Stripping は非対応

Node.js Type Stripping は型注釈の削除だけをやる軽量な実装になっており、JSX がサポートされてない。

Node.js Type Stripping では TypeScript 固有の機能に非対応

Enum, experimentalDecorators, namespaces などには対応してない。Node.js Type Stripping は型注釈の削除だけをやるので、こういう JavaScript にない機能は一切サポートしない。

補足: node --experimental-transform-types

実は --experimental-transform-types を渡すと、単なる型注釈の削除だけでなく、TypeScript 固有の機能のトランスパイルもされる。これを使うと、enum や namespace なども使えるようになる。

とはいえ enum や namespace は esbuild や swc といったトランスパイラでもサポートされておらず、現代では使うべきではないと言われている。よって --experimental-transform-types を使わずに済むなら、そうしたほうが良いと思う。

Node.js Type Stripping は tsconfig.json の paths に非対応

tsx は対応してるけど、Node.js Type Stripping は非対応。

Node.js Type Stripping...というか Node.js 自体が Subpath patterns import に対応してるので、それを使うと import alias っぽいことはできる。

どっちを使えば良いの?

せっかく Node.js に組み込まれてる機能があるのだから、Type Stripping が使えるならそうしたほうが良いと思う。しかし、それができないものもある。

バックエンドサーバー

Node.js で実行されているものなので、Node.js Type Stripping を使ったら良いと思う。

npm package

原則として tsx も Node.js Type Stripping も使うべきではない。というのも、npm にはトランスパイル済みのコードをアップロードするべきだから。Node.js も Type Stripping のおかげで直接 .ts を実行できるようにはなったが、今のところ npm package の .ts は Type Stripping の対象外としている。

To discourage package authors from publishing packages written in TypeScript, Node.js will by default refuse to handle TypeScript files inside folders under a node_modules path. https://nodejs.org/docs/latest/api/typescript.html

tsx や Node.js Type Stripping で開発をして、そのままトランスパイルせずに npm に公開する...ということをしたところで、ユーザの手元で動かない。そのため npm package の開発では tsx や Node.js Type Stripping を使わずに、tsc で事前ビルドするほうが良いと思う。

とはいえどうしても Node.js Type Stripping 使いたい時もあると思う。テスト実行のために Node.js Type Stripping 使いたいとか。そのような場合は、以下のような構成にすると良い。

  • コードベース全体を .ts 付きの import specifier で書く
  • node --test "src/**/*.test.ts" でテスト実行
  • --rewriteRelativeImportExtensions を有効にして tsc で本番向けビルドをする

これでコード上は import specifier を.ts 付きで書けて、最終的な成果物では .js にできる。しかし、一部ややこしい挙動もある。

一応 Node.js Type Stripping 使えなくはないし、使っても良いとは思うけど、オススメするかというと悩ましいな〜。どうしても Node.js Type Stripping を使いたくて、ややこしい挙動を理解した上で使うなら良いと思う。

スクリプトファイル

バッチファイルとか one-time script とかそういうの。基本的に Node.js で実行するものなので、Node.js Type Stripping が使えるならそうしたら良いと思う。

しかし Next.js を使っているプロジェクトで、スクリプトファイルと Next.js で一部モジュールを共有してる、とかだと話が変わってくる。例えば、以下のようなコードがあるとする。

// lib/prisma.ts
import { PrismaClient, type User } from '@prisma/client';
import { getDatabaseURL } from '@/lib/database';

export const prisma = new PrismaClient({
  datasourceUrl: getDatabaseURL(),
});

// 実際はこんな中身のない prisma の wrapper 書かないとは思うけど、仮でこういうのがあるとする。
export async function findUserById(id: string): Promise<User> {
  return prisma.user.findUnique({ where: id });
}

このモジュールは Next.js のコードから使われてて、かつスクリプトファイルからも使われているとする。

// scripts/ユーザ調査.ts
import { findUserById } from '@/lib/prisma';
console.log(await findUserById('123'));

何の変哲も無いスクリプトファイルに見えるけど、node scripts/ユーザ調査.ts するとコケる。

まず import { findUserById } from '@/lib/prisma'; が良くない。拡張子を省略をせず、tsconfig.jsonpaths も使わず、以下のように書くべき。

// scripts/ユーザ調査.ts
-import { findUserById } from '@/lib/database';
+import { findUserById } from '../lib/prisma.ts';
 console.log(await findUserById('123'));

依存先の lib/prisma.ts で拡張子の省略が行われるのも良くない。以下のように書くべき。

// lib/prisma.ts
 import { PrismaClient, type User } from '@prisma/client';
-import { getDatabaseURL } from '@/lib/database';
+import { getDatabaseURL } from './database.ts';
 // ...

このように bundler で実行してる部分とモジュールの共用をしようと思うと、コードベースの書き換えが必要になる。正直面倒だし、ややこしい。

拡張子は明示する、paths やめる、と書き換えていっても良いとは思うけど、それくらいならスクリプトファイルを tsx で実行したほうが楽な気はする。

CLI ツールの設定ファイル

eslint.config.ts, prettier.config.ts, vitest.config.ts など。これは場合による。そもそもこれらのファイルはユーザが実行するというよりは、CLI ツールが内部で読み取って、実行するタイプのもの。CLI ツール側で Node.js Type Stripping を使ったり、tsx を使ったりして実行している。TypeScript の実行に何を使っているかは、CLI ツールによって異なる。

例えば eslint.config.ts は jiti (tsx と同じようなやつ)、もしくは Node.js Type Stripping で実行される。

prettier.config.ts は Node.js Type Stripping で実行される。

vitest.config.ts は特殊で、Vite で bundle して .js に変換した後、Node.js で実行される。

vitest.config.ts は拡張子の省略はできるけど、prettier.config.ts はできない。eslint.config.ts は jiti で動かしてるなら省略できるけど、Node.js Type Stripping ならできない。難しいね...

まあ通常これらのファイルから他のモジュールを import することは稀なので、あんまり困らないとは思う。

おまけ: エディタによる import 文の補完を制御する

エディタ...というか TypeScript の Language Server には import 文を補完する機能がある。その補完で拡張子を省略するのか、明記するのかを制御するオプションが実はある。VS Code なら以下のオプションで制御できる。

  • "typescript.preferences.importModuleSpecifierEnding"
  • "javascript.preferences.importModuleSpecifierEnding"

あとは "@/lib/math" と補完するのか、"./math" と補完するのかを制御するオプションもある。

  • "typescript.preferences.importModuleSpecifier"
  • "javascript.preferences.importModuleSpecifier"

こういうのを上手く使うと、プロジェクト内で import 文の補完方法を上手く制御できるはず。

CSS Modules の拡張構文について

CSS Modules は、CSS をローカルスコープ化する仕組み。*.module.css に CSS を記述すると、bundler がクラスセレクターなどをユニークなものへと変換してくれる。クラスセレクターなどが *.module.css ファイルごとに異なる名前に変換され、擬似的にローカルスコープ化が実現される。

developer.hatenastaff.com

CSS Modules では、基本的には CSS の標準の構文をそのまま利用する。しかし、一部 CSS Modules 独自の構文がある。実際どのようなものがあるのかというのを、紹介する。

CSS Modules の公式ドキュメント (仕様) のリンクを貼りつつ紹介するが、公式ドキュメントの仕様は非常に緩く書かれているので注意すること。真の仕様を把握するには、postcss-modules などの実装も必ず見るように。

:local(), :global() 擬似クラス

擬似クラスの一種で、クラスセレクターをローカルスコープ化するのか、グローバルスコープ化するのかを切り替えるための構文。以下の公式ドキュメントで言及されてる。

以下のようにしてクラスセレクターを囲むと、そのスコープモードを変更できる。

:local(.button) {/* ... */}
/* Bootstrap の .btn-primary を使いたいので、:global(...) で囲む */
:global(.btn-primary) {/* ... */}

:global(...) は UI ライブラリ側で決め打ちされてるクラス名をそのまま使いたいなど、ローカルスコープ化されると困るときによく使う。一方 :local(...) については、基本的に付けても意味がない。通常 CSS Modules ではデフォルトでローカルスコープであるため。しかし css-loader ではデフォルトのスコープモードを変更することができる。デフォルトのスコープモードが "global" の場合、ローカルスコープ化するために :local(...) が使われる。

とはいえデフォルトのスコープモードを "global" にすることまずないので、:local(...) の出番はほぼないと思って良い。

参考情報:

:local, :global 擬似クラス

さっきのやつの括弧 () が無いバージョン。以下の公式ドキュメントで言及されてる。

括弧有りとの違いは、スコープモードの変更範囲である。括弧有りでは「括弧の中のものだけ」が、括弧無しでは「疑似クラスに続くもの全て」がスコープの変更対象となる。

/* a_1 はローカル、a_2, a_3 はグローバル */
.a_1 :global .a_2 .a_3 {}

/**
 * :local で途中でスコープモードを変更可能。
 * b_1, b_4 はローカル、b_2, b_3 はグローバル。
 */
.b_1 :global .b_2 .b_3 :local b_4 {}

...というのが表面的な仕様なのだけど、(CSS Modules の主要な実装である) postcss-modules ではもっと複雑な仕様になってる。セレクターリストや擬似クラスと組み合わせると、非常に奇妙な動作をする。ここで紹介するには難しすぎるので、興味ある人は css-modules-kit のテストケースにその挙動の説明が書いてあるので、それを見てほしい。

参考情報:

@value アットルール

アットルールの一種で、変数を宣言するための構文。以下の公式ドキュメントで言及されてる。

以下のようにして使う。

/* Header.module.css */
@value red: #FF0000;
@value headerHeight: 30px;

.header {
  height: headerHeight;
  background: red;
}

Value/Variables といっても bundle 時に展開されるもので、「bundle 時定数」「コンパイル時定数」などと言ったほうがわかりやすいかも。Sass の Variables にかなり近い。

ただし Sass の Variables と違い、変数が *.module.css から export される。以下のようにして、コンポーネントファイルから変数を参照できる。export される型は常に string

// Header.tsx
import styles from './Header.module.css';

styles.red; // '#FF0000'
styles.headerHeight; // '30px'

また、他の *.module.css から変数を import することもできる。

/* common.module.css */
@value red: #FF0000;
@value white: #FFFFFF;
/* Header.module.css */
@value red, white from './common.module.css';
@value headerHeight: 30px;

.header {
  height: headerHeight;
  background: red;
  color: white;
}

参考情報:

composes プロパティ

mixin みたいなことをするやつ。以下の公式ドキュメントで言及されてる。

あるルールセットで定義されてるプロパティを、別のルールセットに取り込める。

/* https://github.com/css-modules/css-modules/blob/master/docs/composition.md より引用 */
.className {
  color: green;
  background: red;
}

.otherClassName {
  composes: className;
  color: yellow;
}

上記のコードは以下と同じ意味になる。

.className {
  color: green;
  background: red;
}

.otherClassName {
  background: red;
  color: yellow;
}

複数のルールセットをまとめて取り込んだり、複数の他のファイルのものを取り込むこともできる。

.a_1 {
  color: green;
  background: red;
}
.a_2 {
  composes: common_1 from './common.module.css';
}
.a_3 {
  composes: a_1 a_2;
}

composes: global_1 from global; みたいにグローバルから取り込む機能もあるらしいが、正直どういう挙動をするのか id:mizdra はよくわかってない。どのスタイルシートから取り込まれるの?

@keyframes :local(<custom-ident>), @keyframes :global(<custom-ident>)

実は CSS Modules は、デフォルトで @keyframes で定義した名前をローカルスコープ化する。ローカルスコープ化されるということは、それをグローバルスコープ化したい時もある訳で、そのための構文がこれ。CSS 標準では @keyframes <custom-ident> だけど、CSS Modules ではそれを拡張して :local():global() で囲めるようにしてる。

公式ドキュメントでの言及は僅かで、以下で @keyframes :global(<custom-ident>) という構文があることが匂わされてるくらい。

@keyframes :global(<custom-ident>) だけでなく、デフォルトのスコープモードが "global" の時のために、@keyframes :local(<custom-ident>) も用意されてる。

参考情報:

animation, animation-name プロパティ

@keyframes で定義した名前は animation-name と、その一括指定プロパティである animation からも参照できるので、それも拡張されてる。公式ドキュメントでの言及はちょっとだけ。

@keyframes と同じく、アニメーションの名前を :local(), :global() で囲めるような拡張がされてるだけ。

var(<custom-property-name> from <string>)

実は lgithningcss では、カスタムプロパティ もローカルスコープ化される。

/* https://lightningcss.dev/css-modules.html#local-css-variables より引用 */
:root {
  --accent-color: hotpink;
}
.button {
  background: var(--accent-color);
}

加えて他の *.module.css のカスタムプロパティを参照する機能もある。そのための拡張構文が var(<custom-property-name> from <string>) である。

/* https://lightningcss.dev/css-modules.html#local-css-variables より引用・一部改変 */
.button {
  background: var(--accent-color from './vars.module.css');
}

ちなみにこの機能は lightningcss 独自のもので、postcss-modules などの他の実装ではサポートされてない。CSS Modules の公式ドキュメントでも一切言及がない。

:import, :export

ICSS で使われる構文として、:import, :export がある。ICSS は postcss-modules 内で使われる中間表現で、import/export するクラスセレクターなどが明示的に記述される。その明示的な記述を行うための拡張構文がこれ。

詳しくは以下のリポジトリの README を見ると雰囲気がわかる。

まず人間が手で書くことはないので、忘れて良い。

拡張構文の問題点

これらの拡張構文だが、問題点が色々ある。例えば @keyframes :global(.foo) が奇妙という点。セレクターを書く場所じゃないのに、擬似クラスの構文 (:global()) が使われていて変だと思う。

xxx from './vars.module.css' のような構文が animation プロパティなどで使えなくて、一貫性がないのも微妙。そもそも仮に animation: slide-in from './vars.module.css' と書けたとして、どれがアニメーションの名前で、easing-function の名前なのか定まらなくて、破綻してしまうと思う。

拡張構文があることで将来の CSS の新構文と衝突してしまう恐れがあるのも問題だと思う。@value アットルールや composes プロパティなど全く独自のものならともかく、var(), @keyframes, animation-name など既存の構文を拡張しているものは、結構危ういと思う。できるだけ CSS の標準の構文を使用することで CSS の仕様変更に追従しやすい、というのが CSS Modules の良さだと個人的には思ってて、そこが損なわれてるのが勿体ないと思う。

理想的にはどうあるべきか

:global(), xxx from global, xxx from <string> といった記法を廃止すべきだと思う。代わりに、以下のように「ある識別子をグローバルスコープにするのか、他のファイルから import してくるのか」を示せる構文を追加する。

@cm-global btn-primary, slide-in;
@cm-import red, flex from './common.module.css';

/* a_1 はローカル, btn-primary はグローバル */
.a_1 .btn-primary {}

/* slide-in はグローバル */
@keyframes slide-in {}

/* a_2 はローカル, red は common.module.css のもの */
.a_2 .red {}

.a_3 {
  /* flex は common.module.css のもの */
  composes: flex;
}

一応似たような提案が以下の Issue で行われているようだった。議論が進むと良いなー。

TSKaigi 2025 に参加しました

色々セッションを聴いてきたので、その感想です。沢山ありすぎたので、聴いてて気になったものだけ感想書いてます。

The New Powerful ESLint Config with Type Safety

https://talks.antfu.me/2025/tskaigi/1

ESLint の Flat Config についてのセッション。新 config への移行を補助するツール (@eslint/migrate-config, Config Inspector) が色々紹介されていた。存在そのものは知っていたけど、実際どういうことができるのかとか、動いている様子とかは見たことがなかったので、そういうのがセッションで見れて良かった。

@eslint/migrate-config はまあ良さそうだけど、Config Inspector はそんなに必要な状況あるかなあとは思った。そもそもそれが必要になるほど config のデバッグに苦労する状況が良くないと思う。複雑な config は避けて、シンプルで挙動が予測しやすいものをまず志向すべきだと思う。まあでも本当にどうしようもなく難しい config を作ってしまった時に、デバッグするのには便利そう。

config の合成を補助する eslint-flat-config-utils というのも紹介されてたけど、これも本当に必要かな?と思った。config の合成が簡単にできるのは良いけど、ESLint の標準とは異なる方法で合成するから、その合成のルールを学ばないといけないと思う。新しいこと覚えるの手間なので、ESLint の標準的な方法で合成するので十分じゃないかと思う。

あと plugin で追加された rule の名前を rules の補完候補として出す「eslint-typegen」は面白かった!僕も補完できるようにしたいなとずっと思ってた。eslint 実行時に plugin が動的に型定義ファイルを生成して、その型定義を元に rules の補完ができるようにする、というのがだいぶカッコ良い。動的に型定義を生成する仕組み、だいぶハックっぽいのでもうちょっと単純な仕組みになって、かつ ESLint 側に組み込まれたらより良いかなと思った。

SignalとObservable―新たなデータモデルを解きほぐす

https://blog.lacolaco.net/posts/tskaigi-2025-slide/

Angular/Vue.js/SolidJS/Preact など現代的な View フレームワークに実装されている Singals は、実は昔から似たようなものがあるんですよ、という話がされていて良かった。現代的な View フレームワークから入った人の多くは知らないだろうから、そういう人に見てもらう資料として良いなと思った。

後半は Observable の解説があって、実は DOM API に追加されようとしてるんです、という話があってへーとなってた。あんまりこの辺の動向追ってなかったので、色々知れて良かった。

Language Serverと喋ろう

https://speakerdeck.com/pizzacat83/language-server-todie-rou-tskaigi-2025

エディタの Show Call Hierarchy 的なことをやって、ある処理が呼び出される経路を一覧するツールを作る、という話。TypeScript なら ts-morph 使えばシュッと実装できるけど、そうではなくて、どのような言語でも動くようにしたい。そのために Language Server Protocol を使う、という発想がすごく面白かった。

エディタの中で動くツールならまだしも、それ以外のツール (CLI ツールとか) で Language Server を使う事例は殆ど聞いたことないので、こういう使い方もできるんだなと思った。真面目な話をすると、自作ツールから Language Server を起動することで発生する面倒事も色々ありそうだなと思った。エディタは Language Server を起動するための推奨オプションを知ってるけど、自作ツールは知らない訳で、エディタの設定を見ながらオプションをハードコードすることになるはず。

AI Coding Agent Enablement in TypeScript

https://speakerdeck.com/yukukotani/ai-coding-agents-enablement-in-typescript

型チェックや Linter を駆使して、AI Coding Agent が脱線せずコーディングできるようにしましょう、という話。Linter が効くという話はよく効くけど、このトークでは実践的な話が色々なされていて面白かった。AI を激詰めしてから lint rule 作ってとお願いするのは今日からすぐ使えるテクニックだなと思った。

TypeScriptとReactで、WAI-ARIAの属性を正しく利用する

https://docs.google.com/presentation/d/1rzznSwA7da7S_lU6qyAFuCN9IC1uDJe44PvDg-uqHjQ/mobilepresent?slide=id.p

DAY1 で一番面白かったセッションだった。TypeScript コンパイラ側の仕様で、WAI-ARIA 属性の型チェックが緩くなる問題があり、それを解決するために色々なテクニックやツールを駆使する、という話だった。

問題の回避の方法が結構巧妙で、聞いていてなるほどと思った。良さそうと思いつつ、コードの書き方を変える必要があるから (親から受け取った props を aria-attribute-types で変換する) 、中々導入しづらい気はする。しかし他に良い方法もなさそうだし...うーん...と考えさせられるセッションだった。

コードの書き方はそのままで、問題を回避する方法があれば良いんですけどねえ。Linter や TypeScript Language Service Plugin 使ったら何とかならないかなあ。...と考えて色々 X にポストを投稿してた。けどやっぱり良い方法思いつかない!

Rust製JavaScript/TypeScript Linterにおけるプラグイン実装の裏側

https://speakerdeck.com/unvalley/typescript-linters

Rust 製の Linter で、Plugin を JavaScriptで書こうとすると、Rust => JavaScript に AST を転送する際に高コストなデシリアライズが必要になり、遅くなってしまう問題がある。それをどう解決するか、という話だった。この発表で紹介された話は https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-11/ で解説されてて、僕はそれを読んだことがあったので、ふんふんと頷きながら聴いていた。

AST を json-like な object にして、それを JSON としてデシリアライズすると高コストになってしまう。だから AST を1次元の数値の配列に何とかして変換して、それを送る。1次元の数値の配列なら、低コストで転送でき、遅延デシリアライズと組み合わせると、問題が解決する、というのがこのセッションの面白ポイントだった。AST の一部をどんどん簡略化していって、最終的に1次元の数値の配列になるというのがわかりやすく解説されてて良かった (まあ扱う問題が中々難しいので、なんとなくの理解に留まっている方も結構居そう)。

常にこういうアプローチを取れる訳ではなくて、lint rule は多くの場合 AST の全ての Node を走査することがない。rule の実装に必要な最小限の Node だけ走査されるので、その Node だけ遅延デシリアライズしたら良い。そういう lint rule 特有の実行特性があるからこのアプローチが取れる。AST の全 Node を走査する必要がある JavaScript Plugin システムでは、同様のアプローチを採用しても、パフォーマンス向上に寄与しない...という理解を僕はしている。それがこのアプローチの面白い点だと思っていて、折角なのでそういう話もセッションでされると良かったなーと思った。

TypeScriptネイティブ移植観察レポート TSKaigi 2025

https://speakerdeck.com/berlysia/typescript-native-porting-observation-tskaigi-2025

tsgo がどのようなもので、どういう戦略で開発されてるか、今どういう開発状況なのかがまとまってて良かった。P42 のまとめスライドが良いことばかり書いてあって良かった。『「本家本元がやる」だけでは埋まらない不安が「ちゃんと動いていて本当に速い」で吹き飛ぶ』というのは本当にそう思う。

TypeScript Language Service Plugin で CSS Modules の開発体験を改善する

聴いたというよりは発表しました。

www.mizdra.net

技術書をソフトウェア開発する - jsprimerの10年から学ぶ継続的メンテナンスの技術

https://azu.github.io/slide/2025/tskaigi/jsprimer.html

JavaScript の仕様が変化していく中で技術書側をそれに追従させる工夫が、様々な観点で紹介されていて面白かった。単に丁寧にやっていくという話ではなくて、ツールを作って導入したり、章の流れを可視化して章の順序を決めるのに役立てたりなど、普通の書籍の執筆では見れれないような工夫が紹介されてたのが印象的だった。本当にソフトウェアを開発するかのように技術書の執筆をしていてすごい。

君だけのオリジナル async / await を作ろう

https://speakerdeck.com/susisu/tskaigi-2025

同僚の発表ということで聴いた。ジェネレータを使えば async/await っぽいことができる、というのは知っていたけど、実際どうそれを作るのかは知らなかったので、へーと言いながら聴いていた。意外とこれくらい単純なコードで実装できるんだなー。題材が難しかったけど、資料が分かりやすかったのですんなり頭に入ってきてよかった。

TS特化Clineプログラミング

https://tskaigi.mizchi.workers.dev/

AI Coding Agent 向けの実践的なテクニック集という感じだった。途中で TDD が大事という話があったけど、説明の根拠を聞く限りは TDD が大事というよりテストがあることが大事というふうにも理解できて、どうなんでしょうねと思った。TDD じゃなくてもテストがあれば、Agent が生成するコードの質そんな変わらないんじゃないのかな。

In Source Testing で、テストと実装を同じファイルに書くことで、Agent がテストコードと実装を一度に読み取れて扱いやすい、というのは確かになと思った。In Source Testing、流行ってるけどどうなんでしょうね。テスト向けのコードが production コードに bundle されてしまう恐れとかはないのかな。bundler が production ビルドする時に、テスト向けのコードをちゃんと dead code elimination なり tree shaking なりしてくれれば良いけど、どこまでやってくれるんでしょうね。エッジケースが怖くて僕はまだ In Source Testing 使えてない... まあでもどこかで試してみると良い気がする。

令和最新版TypeScriptでのnpmパッケージ開発

https://speakerdeck.com/lycorptech_jp/tskaigi-2025-odan

色々ビルドツールがあるけど、どういう時何使えば良いの? という疑問に答えてくれる発表だった。あと過剰に package 分割すべきではないよねという話がなされていて、うんうんと頷いてた。

最近 monorepo がトレンドになって、どんどんパッケージ分割するのが良い、みたいな風潮があるけど、そんな訳ないと思う。例えば関数1つごとにパッケージ1つ作ったら良いかというと、そんなわけがない。dependencies が増えてきたら分けるという人も居るけど、本来1つの package に収まっていて綺麗に書けていたものを、どうにかして分割すると、無理が出てくると思う。package を分けて運用するコストは無料じゃない。僕は運用したことないけど、マイクロサービスとかとも無理に分けまくって困るというのはよく聞く話で、それと似た話題だと思う。開発チームが違うとか、デプロイの単位が違うとか、責務が違うとか、そういう意味のある単位で分けて欲しい。分けるべき時が来たら分けたら良い。それまでは分けずに粘るべき。...という話を Ask the Speaker で話して盛り上がってた。

Project Referencesを活用した実行環境ごとのtsconfig最適化

https://speakerdeck.com/itatchi3/project-referenceswohuo-yong-sitashi-xing-huan-jing-gotonotsconfigzui-shi-hua

Project References...というかSolution Style tsconfig を利用した、tsconfig.json の分割方法についての発表だった。簡潔にまとまってて、他の人にオススメしやすい資料だった。

types: [] を付けると、@types ディレクトリ配下の型定義ファイル全て (@types/node 以外のものも) 読み込めなくなる気がするけど、大丈夫なのかな、トラブル起きないのかなと思ったけど、どうなんでしょうね。今まで自動で読み込まれていたはずの型定義を、明示的に指定して手動で読み込む必要があるので、そこがユーザ的には混乱しそう。

少し話が変わるけど、最近 tsc --init で生成されるデフォルトの tsconfig.json を更新しようという動きがある。

実はこの議論の中で、tsc --init で生成される設定に types: [] を追加しようと提案されてる。そして自動で型定義読み込まないことで混乱が起きるんじゃないか、とちゃんとユーザから突っ込まれてた。それに対しメンテナーの方は、理解を示しつつも自動で型定義読み込むほうが厄介な問題引き起こしてるので、自動を読み込み禁止したい、とりあえず試して様子を見たい、と言っているようだった。

tsconfig.json 周りはまだしばらく大きな変化がありそうだなと思った。

総括

型の話ばっかりという感じではなくて、色々な方面の話が聞けたので面白かった。一方でレベル感的にはちょっと物足りなさも少し感じた。もうちょっと難しい話があっても良かったかも? どうかな? 折角 3 トラックあるので、難しい話が多少あっても、上手い具合に人が分かれてくれる気はする。まあでも人によって聞きたいレベルの感覚違うし、今ぐらいが丁度良いのかなー。

あと TypeScript ではないけど Flow の話とかしたら絶対盛り上がるなと思った。興味ある方よろしくお願いします。

TSKaigi 2025 で CSS Modules Kit について発表しました

「TypeScript Language Service Plugin で CSS Modules の開発体験を改善する」というタイトルで発表しました。

speakerdeck.com

CSS Modules には、Find All References などの言語機能が動かないといった問題があります。本発表では、その問題を解決するためのツールキット「CSS Modules Kit」を紹介しました。

github.com

CSS Modules Kit は、Find All References といった主要なな言語機能をサポートしています。更に、VS Code 以外の様々なエディタで動くという特徴があります。その裏側では TypeScript Language Service Plugin と Volar.js を駆使した、非常に巧妙な実装がなされています。

CSS Modules を使っている方にとって、興味深い発表になっているかと思います。CSS Modules ユーザ以外の方にとっても、エディタの言語機能の仕組みを知ることができる、良い発表になってると思います。是非読んでみてください。

頂いた質問とその回答

X でいくつか質問頂いたので、それぞれ回答させてもらいます。

質問: codegen はどこに .module.css.d.ts を出力する?

デフォルトでは、プロジェクトルート *1 直下の generated ディレクトリにされます。例えば、以下のようなプロジェクトがある時、

project/
├─ src/
│  ├─ App.module.css
│  ├─ App.tsx
│  ├─ components/
│  │  ├─ Button.tsx
│  │  ├─ Button.module.css

codegen を実行すると、以下のように generated ディレクトリが生成されます。

project/
├─ src/
│  ├─ App.module.css
│  ├─ App.tsx
│  ├─ components/
│  │  ├─ Button.tsx
│  │  ├─ Button.module.css
├─ generated/
│  ├─ src/
│  │  ├─ components/
│  │  │  ├─ Button.module.css.d.ts
│  │  ├─ App.module.css.d.ts

ただ generated ディレクトリに生成しただけでは、tsc はその型定義を .module.css の型定義として使ってくれません。そこで CSS Modules Kit では src/** にあるモジュールの型定義を、generated/src/** から読み込むよう、tsconfig.jsonrootDirs オプションを設定する前提になってます。

happy-css-modules *2 (CSS Modules Kit の前身のツール) はデフォルトではコンポーネントの横に .module.css.d.ts を出力していて、src/ 配下に自動生成ファイルが混じって煩わしくなっていました。CSS Modules Kit ではそこが解消されていて、快適にお使いいただけるようになってます。

質問: Language Server が LPS request を受け取る優先度は変えられる?

エディタによっては変えられます! Zed はできます。というかスライドの P63 で紹介した設定がまさにそれですね。

ここでは .css の LS の優先度を vtsls (tsserver のラッパー) > それ以外の Language Server に変更してます。これにより、Zed で Rename request の取り合い問題 (P65) を回避してます。

Zed ではこれが可能ですが、残念ながら VS Code はできません。そのため VS Code では Rename request の取り合い問題が起きているのです...。NeoVim、Emacs は試してないので分からないです。設定柔軟に書けるし、多分変えられるんじゃないかなー。

ちなみに Vue.js や Astro にも CSS Modules Kit と同じように TypeScript Language Service Plugin を使った言語ツール (@vue/typescript-plugin, @astrojs/ts-plugin) がありますが、そちらでは Rename request の取り合い問題は起きていません。何故なら .vue/.astro などの独自の拡張子のファイルには、エディタ組み込みの Language Server がないからです。tsserver と Rename request を取り合う相手が居ないのです。

一方 CSS Modules Kit の場合は、.css の Language Server に tsserver と vscode-css-languageserver の2つが居ます。Rename request の取り合いは、CSS Modules Kit が .css に言語機能を提供するために起きる、特有の現象な訳です。

もしかすると VS Code 開発チームに CSS Modules Kit のユースケースを伝えれば、VS Code 側で何かしらの対策を取ってもらえるかもしれません。問題の解決策が提供されてないのは、単に今までユースケースが無かったから、というのも理由の1つだと思うんですよね。そのうち要望出そうかなと考えてます。

質問: :local():global() (CSS Modules の拡張構文) はサポートしてる?

サポートしてます!ただ、一部制限があります。:local(.button):global(.button) のように括弧を使った記法はサポートしてますが、:local .button:global .button のような括弧を使わない記法はサポートしてません。

括弧なしの記法も当初はサポートするつもりだったのですが、あまりに仕様が複雑だったので諦めました...。postcss-modules-local-by-default:local / :global の実質的な仕様になってるのですが、なんというか実装に引きずられて変な仕様になってるんですよね... 具体的にどこが変なのかは CSS Modules Kit のテストケースにメモを書いてるので、それ見てください。

びっくりする挙動が多いので、そもそもこの記法を使うべきではない、と id:mizdra は考えてます。そのため、CSS Modules Kit では意図的に括弧なしの記法をサポートしてません。

ちなみに、同じく CSS Modules の拡張構文である @value にも対応してます。@value で定義された変数は、.module.css.d.ts の型定義に含まれます。また、Bundler には @import ... で import したスタイルシートを、bundle 時に import 元へと展開する機能がありますが、それにも対応してます。@import ... で import したスタイルシートに書かれていたクラスも、.module.css.d.ts の型定義に含まれます。

CSS Modules Kit は他のツールからの乗り換えを容易にするために、エコシステムの分断を避けるために、既存のツールとの互換性を重視してます。足りない機能があったら実装を検討するので教えて下さい。:local .button:global .button のように、必ずしも実装するとは限らないですが。

質問: LSP の仕様で言語横断の言語機能を提供する仕組みが規定されていないのは何故?

言語横断の言語機能を提供したい、あるいは言語Aと言語Bの Language Server 同士で通信したい、といった要望は以前からあって、LSP の仕様リポジトリの issue で議論されてるようです。

このうち #636 のコメントを見ると、言語横断の言語機能を提供したければ、複数の言語の LS を束ねるメタ LS (proxy のようなもの) を作るよう推奨されています。CSS Modules Kit の ts-plugin もこれに分類されるものですね。

現状メタ LS のようなものがあればやりたいことは実現できますし、複雑な機能を入れるよりは、仕様をシンプルに保ちたい、という意図があるのだと思います。

また、そもそも複数の LS を束ねた時の挙動が曖昧すぎて、仕様で定めるのが難しいというのもあると思います。やろうと思えば completion response が複数の LS から返ってくるようにできますが、それのどちらを優先するのか、マージするのか、マージするにしてもどの順でマージするのか、決めようにも決めにくいところがあるはずです。ユースケースによってどうすべきか変わりますし、仕様で決めるよりはメタ LS 側で自由に決められるほうが、色々と都合が良いんじゃないかなと思います。

さいごに

当日は多くの方に発表を聴きに来ていただきました。多くの人に CSS Modules Kit の取り組みや仕組みを知ってもらえて嬉しかったです。ありがとうございました!

実は CSS Modules Kit には、発表では話せなかった面白トピックが他にも沢山あります。そちらは順次ブログに書いていこうと思います。お楽しみに。

聴いたセッションの感想はこちら。

www.mizdra.net

*1:tsconfig.json のあるディレクトリのことです。

*2:これも id:mizdra が作ったものです。https://www.mizdra.net/entry/2022/11/14/102506 を読んでください。

Next.js 14.0.0+ で Pages Router / basePath / Middleware 併用時に発生する不具合とその回避策について

社内のあるプロダクトで Next.js を v13 系から v15 系にアップデートしたところ、トップページにブラウザバックで戻ると、エラーが発生するようになってしまった。

エラーの原因を調べてみると、何故かトップページにブラウザバックで戻った時、pageProps が空オブジェクトになっているようだった。

不具合再現の様子。ブラウザバックでトップに戻ると、pageProps が空になる。

実はこれは Next.js の不具合で、以下に issue がある (まあ僕が報告したのだけど...)。

github.com

Next.js 側で修正されるのを待ってたのだけど、不具合の報告から1年以上経っても何の音沙汰もない。困り果てたので、なんとか不具合を回避する方法がないか、探すことにした。

不具合の発生条件

この不具合はいつでも再現する訳ではなくて、以下の条件をすべて満たす時発生するようになってる。

  • Next.js 14.0.0+ を使っている
  • basePath オプションを使っている
  • Middleware を使っている
  • トップページを Pages Router でレンダーしている
  • トップページへ soft navigation で遷移する

すべて満たす時発生するので、どれかが欠けると発生しない。つまり発生要件のどれかを満たさないようにすれば、問題を回避できるはず。そう考えて、どれかを満たさないようにする方法がないか模索することにした。

回避策の検討

基本的には、以下のどれかが回避策として利用できるはず。

  • 方法1: basePath オプションの使用をやめる
  • 方法2: Middleware の使用をやめる
  • 方法3: Pages Router の代わりに App Router でトップページをレンダーする
  • 方法4:トップページへ hard navigation で遷移する

ただ方法1については、我々のプロダクトではbasePath オプションがどうしても必要なプロダクトだったため、採用できなかった。方法2も、プロダクトの重要なところで Middleware を使っていて他の技術への置き換えが難しく、採用できなかった。

方法3は将来性があって優れてるけど、Pages Router から App Router に移行するのが大変なのが気がかりだった。我々のプロダクトでは getServerSideProps に複雑なロジックが書かれているのだけど、getServerSideProps は App Router にはない。App Router では getServerSideProps に書いていたロジックを Server Component に書くことになる。だいぶ書き方が違うため、どうにも移行が難しかった。他に良い方法がないなら選択しても良いけど、気軽に選びにくい方法だった。

他に何か良い方法がないか...と悩みに悩んで思いついたのが方法4だった。トップページへ soft navigation で遷移すると不具合が発生するのだから、hard navigation で遷移したら不具合は発生しないはず。これならアプリケーションの機能を殆ど損なうこともなく、大幅なコードの書き換えもなく導入できるはず。そう考えて、方法4を採用することにした。

回避策の実装

以下に全てが書いてあるので、それを見て欲しい!

重要なポイントは以下。

  • <Link> component をラップし、トップページの遷移を hard navigation に fallback させる
    • import Link from 'next/link'import {Link} from '@/components/Link' に置き換える
  • useRouter() をラップし、トップページの遷移を hard navigation に fallback させる
    • import {useRouter} from 'next/link'import {useRouter} from '@/lib/useRouter' に置き換える
  • Router.beforePopState を使用し、トップページへのブラウザバック/フォワードをキャンセルし、ハードナビゲーションに fallback させる

とにかく soft navigation でトップページに遷移してしまう経路を握りつぶしたら良い。import 文の書き換えがちょっと面倒ではあるけど、 ts-fix で足りない import を自動で追加してみる のような手法で機械的に書き換えたら良いはず。

一応手元で確認した限りは、これで問題なく不具合を回避できた。

回避策を導入した後の様子。トップに遷移しても pageProps が空にならない。

おわりに

この記事で紹介した回避策は、僕の社内のプロダクトで必要に迫られて導入したものだったけど、多分世の中にも必要なプロダクトがたくさんあると思う。つい最近もかなり古い Next.js バージョンを使ってる、basePath を利用するプロダクトを見かけた。真相はわからないけど、僕と同じような不具合にハマってバージョン上げられてないのかなと想像していた。

この記事がそういう人のためになればと思う。

おまけ

basePath オプションは他にも奇妙な不具合がある。Pages Router / basePath / rewrites / Middleware 併用時に発生する以下の不具合とか。何故かある長さより小さいパスのページにするとエラーになる。5文字のパスのページには遷移できるけど、4文字のパスのページには遷移できない、という不思議すぎる挙動をする...

github.com

basePath が悪いのか、Pages Router を使っているのが悪いのか分からないけど、とにかくそれ関連の組み合わせがめちゃめちゃバグっている印象がある。Pages Router がもうアクティブに開発されないことを考慮すると、今後も新しい Next.js で basePath / Pages Router 併用時に発生する新たなバグが混入して、それが修正されず放置される可能性も高いだろう。

将来を見据えるなら、今回の記事で紹介したような一時しのぎ的な解決策ではなく、App Router への移行をすべきだと思う。あと basePath 使わずに済むならそれに越したことはない。まあそれが難しいから皆困ってるんだろうけど...

RubyKaigi 2025 に参加した

同僚の id:onk さんや id:Pasta-K さんにぜひ来てほしいと誘われたので、参加してみました。普段は JavaScript ばかり書いてて 全く Ruby 書いたことないです。RubyKaigi 初参加です。せっかくなので感想を書いておきます。

プログラミング言語の話が多い

「RubyKaigi は Ruby の言語開発者が話すカンファレンスで、プログラミング言語の話ばかりしている」と事前に聞いていて心構えはしていたのですが、その想像の3倍はプログラミング言語の話をしていて驚きました。JIT の話、静的型の話、irb の話、ビルドシステムの話、パーサーの話、Linter の話、Language Serevr の話、などなど。それだけならまあプログラミング言語のカンファレンスだしな、と思えたけどそれらのテーマのセッションが数個あって、マジかよとなってました。つまり JIT / 静的型 / irb / ビルドシステムの話などを、3日のうちに何度も聞くことになります。これに 1500 人集まってるの本当に???と思いました。

書いたこともない言語の話を聞いて楽しめるのか? と正直不安だったのですが、かなり楽しかったです。id:mizdra 自身はプログラミング言語の話が好きで、それ関連の話題に馴染みがあったので、それなりにセッションにはついていけていました。あと Ruby のリリースノートは普段から目を通していて、RBS や Prism などのことは知っていたので、話が頭に入ってきやすかったというのもあると思います。

一方で、会場にいた RubyKaigi 初参加者に話を聞いたところ、(Ruby 経験ある人もない人も) セッションの内容が難しくて理解するのが大変だったと言ってる方が多かったです。そりゃそうですよね。

正直セッションのレベル高すぎないかと思ったのですが、id:Pasta-K さん曰く前年の RubyKaigi の続きの話をするセッションが多いので、何度も RubyKaigi に足を運んでいるとだんだんと理解できてくるそうです。そう言われると確かに「これは前回の RubyKaigi のセッションの続きで〜」と言われてるやつが多かったです。初回参加でめげずに来年も参加してみると、違った景色が見れそうです。

あと話の細部は分からずとも、「なんか複数のセッションで度々 Prism ってやつ話題になってたし、それが最近はアツいんだな〜」という印象は、参加者なら誰でも得られたのではないでしょうか。そういうワードを意識して Ruby のリリースノートを読むと、より楽しめそうだなと思いました。

自由に書けることを良しとしてる

RubyKaigi で最も印象的だったセッションは、TRICK 2025 でした。Ruby の構文の表現力を活かしたアート (プログラム) を Rubyist が事前応募で投稿して、その中でとくに優れたものを表彰するセッションでした。何年かに1度、RubyKaigi の中で不定期に開催されているイベントのようです。何年か前に SNS で話題になった「金魚が泳ぐ Quine」は、TRICK2022 で発表されたものだそうです (知らなかった)。

今年の分は以下のリポジトリで公開されています。超絶技巧すぎて解説がないと理解するのは難しいと思います... ぜひセッションの映像が公開されたらそれを見てみてください。

github.com

普段 JavaScript 書いている人ならではの感想だと思うのですが、「何でも自由に書けるということを、ここまでポジティブにいじって良いんだ」と感じました。よく SNS で「JavaScript はこんな変な書き方ができてしまう! 駄目な言語だ!」と話題になることもあって、変な書き方ができることを良しとする文化がとても新鮮でした。言語の違い、文化の違い、コミュニティの違いを強く感じるセッションでした。

観光 (行き)

松山という立地が中々大変でした。交通の便が悪すぎる! 飛行機で行くのが最も楽だと思うのですが、何ヶ月も前に事前に予約したり保安検査通れるように気を遣ったりするのが面倒すぎて、基本電車で行くことにしました。飛行機組の2倍以上時間は掛かるけど...

折角なので鳴門観光していくかと思って、行きは鳴門に寄りました。とても景色が良かったです。

鯛茶漬けと絶景

渦潮の観光船に乗ろうと思っていたので、ご飯を食べた後は港へと向かいました。ただ、歩いているうちに雨が降り出し、風も強くなってきました。

一瞬で視界が最悪に

港についた頃には暴風雨でした。まあそんな予感はしてましたが船は休航になりました。

仕方がないので港を出て、橋の下部にある「渦の道」という遊歩道へと向かいました。こちらからは雨でも渦潮が見れて良かったです。

このあと高松へとバスで向かって、その後特急 (いしづち号) に乗って松山まで向かう...予定だったのですが、バスが遅れて乗りたかった特急に乗り遅れました。1時間後にもう1本走っていたので松山には行けるのですが、それまで高松で足止めされてました。まあ仕方ない。

1時間気長に次の電車を待ってたら、アンパンマン仕様のやつが来ました。コンテンツ力が高い。

観光 (帰り)

松山城、道後温泉に寄りました。どっちも良かったです。道後温泉 本館、もっと広いかと思っていたのですが意外と小さくてへえとなってました。松山城は景色が良かったです (あと暑かった!)。

帰りのルートは特急 (しおかぜ号) と新幹線に乗って、松山 => 岡山 => 東京 のルートで帰りました。しおかぜ号は瀬戸大橋を渡る特急電車です。とても景色が良くて満足度が高かったです。

おわりに

ちゃんと楽しめるか不安だったのですが、僕が好きな話ばかりされていたので結構楽しめました。僕と同じようにプログラミング言語大好きな人が行ったら、同じように楽しめると思います。

Kaigi on Rails も気になってるので、次はそちらに行ってみようかなと思いつつあります。

4 ステップでモダンな tsconfig.json を作る

tsconfig.json を使うと、型チェックを緩く/強くしたり、また出力する JS の形式を変えたりできる。しかしいくつかの事情から、正しく書くのが難しい。

  • オプションの数が非常に多い
    • その数なんと 133 個 *1
  • オプションの意味や役割が理解しにくい
    • 公式ドキュメントは丁寧にかかれているが...
    • JavaScript や TypeScript の仕様、型の知識、歴史的経緯などを知らないと理解しづらい
  • 推奨されるオプションが変わっていく
    • 言語やエコシステムの進化/変化によって変わる
    • 最近だと Node.js の TypeScript サポートで変わった

「オプションの細かい意味とかは一旦いいから、モダンで最小限の tsconfig.json がすぐに欲しい!!!」。そうした声に応えて、id:mizdra がオススメする「4 ステップでモダンな tsconfig.json を作る方法」を紹介する。

CommonJS ではなく ESM を使うこと、Node.js で TypeScript を実行する時は Type Stripping を使うことを前提に解説していく。また、個々のオプションの解説は最小限にとどめて、これをコピペしたら良いという説明にしてる。所々で解説を折りたたんだ状態で書いてるので、オプションの意図を知りたくなったら読んで欲しい。

「記事読むのも面倒だぜ!」という人は

ジェネレータ作ったので、これをポチポチしてください。

簡単 4 ステップ

以下の 4 ステップでモダンな tsconfig.json を作成できる。

  1. 雛形を生成する
  2. Bundler や Node.js Type Stripping で利用できない構文を制限する
  3. プロジェクトの構成に応じたオプションを選ぶ
  4. コンパイル時のチェックを厳しくする

1. 雛形を生成する

実は tsc(コンパイルするコマンド) には、tsconfig.json の雛形を生成する機能がある。TypeScript 公式の推奨オプションも反映されてるため、この雛形から始めるのが良い。

# npm
npm i -D typescript
npx tsc --init
# pnpm
pnpm i -D typescript
pnpx dlx tsc --init

npx tsc --initすると以下のようなtsconfig.json が生成される (説明のために一部整形済)。

{
  "compilerOptions": {
    /* Language and Environment */
    "target": "es2016",
    /* Modules */
    "module": "commonjs",
    /* Interop Constraints */
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    /* Type Checking */
    "strict": true,
    /* Completeness */
    "skipLibCheck": true
  }
}

下 4 つのオプション (esModuleInterop,forceConsistentCasingInFileNames, strict, skipLibCheck) は現代では必須とされるもの。まず外すこともないので、これらの意味は知らなくて良い。とにかく付けておくこと。

それ以外はこの後のステップで変えていく。

2. Bundler や Node.js Type Stripping で利用できない構文を制限する

TypeScript には複数の *.ts ファイルを解析しないとトランスパイルできない構文がある (難しいので詳細は割愛)。これらの記法は tsc ではトランスパイルできるのだけど、Bundler や Node.js Type Stripping は 複数の *.ts を1ファイルずつ独立して並列でトランスパイルする仕組みであるため、一般にトランスパイルできない。

そこでこうした構文をコンパイラレベルで使用を禁止しておくと良い。以下のオプションを足す。

{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}

解説

"isolatedModules": true を設定すると複数の *.ts を1ファイルずつ独立して変換できるよう、一部構文を禁止できる。

これを単に ON にしておくので十分だけど、現代では "verbatimModuleSyntax": true というより上位のオプションがある。これは、"isolatedModules": true を自動で設定したうえで、更に import 文から型の import を削除する挙動を改善してくれる。

「Bundler や Node.js Type Stripping で利用できない構文を制限する」のとはちょっと違うけど、まあこれを付けておくと良いと思う。

3. プロジェクトの構成に応じたオプションを選ぶ

tsconfig.json は、プロジェクトの構成によって推奨のオプションが異なる。主に"target", "module", "moduleResolution", "noEmit" の 4 つを変えることになる。それぞれどういうオプションというと...

  • "target"
    • どの ES バージョンに downlevel (古い ES バージョンへの変換) するかを指定
    • 主な値
      • "ES2021"/"ES2020"/...: ES20XX に downlevel する
      • "ESNext": 変換せずそのまま
  • "module"
    • モジュールの出力形式 (CJS や ESM のこと) を指定
      • import 文を CJS 形式 (require(...)) に変換するのか、そのままにするのかなどを決められる
    • 主な値
      • "NodeNext": .ctsは CJS、.mjsは ESM、*.jsは (よしなに判定して) CJS or ESM に変換して出力
      • "Preserve": 変換せずそのまま出力
  • "moduleResolution"
    • import ... from 'module-name'module-nameをどう絶対パスに解決するか指定
    • 主な値
      • "nodenext": Node.js が実装してる解決方法を真似る
      • "bundler": 一般的な Bundler が実装してる解決方法を真似る
  • "noEmit"
    • *.jsを出力するかどうかを決める
    • 主な値
      • true: 出力しない
      • false or 省略: 出力する

見ての通り Bundler 利用時はこれ、Node.js 利用時はこれ、というのがある。実際にどういう時にどんな値にすればよいか、よくあるプロジェクト構成ごとに紹介していく。

例 1: Web アプリケーションのフロントエンド向けの構成

以下のような構成のプロジェクトのこと。

  • Bundler でコードを bundle
  • Bundler でトランスパイル (TS => JS)
  • 成果物をブラウザで実行
  • JSX でコンポーネントを書く

この場合、以下のような設定にする。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "Preserve",
    // "moduleResolution": "bundler",
    "noEmit": true,
    "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
    "jsx": "preserve"
  }
}

解説

  • "noEmit": true
    • tsc でトランスパイルしないので
  • "target": "ESNext"
    • "noEmit"は出力形式に関するオプションで、 "noEmit": true併用時は不要に思えるが...
      • targetは出力形式の指定だけでなく、「変換先のバージョンが古すぎて正しく downlevel できない構文をコンパイルエラーにする」機能も兼ねてる
      • "target": "es5" だと、ES5 で表現しきれない一部言語機能 (top-level await など) が使えない
    • コンパイルエラー回避のため、"target": "ESNext" にすべき
  • "module": "Preserve"
    • "module"も出力形式に関するオプションで、 "noEmit": true併用時は不要に思えるが...
      • 出力形式の指定だけでなく、一部構文 (top-level await など) をコンパイルエラーにする機能も兼ねてる
    • コンパイルエラー回避のため、"module": "Preserve"にすべき
  • "moduleResolution": "bundler"
    • Bundler を使ってるので
    • "module": "Preserve"の時は自動で設定されるので省略可
  • "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"]
    • DOM API の型定義を読み込むために必要
  • "jsx": "preserve"
    • JSX を記述するために必要

例 2: バックエンドサーバー向けの構成

以下のような構成のプロジェクトのこと。

  • node src/server.tsで実行 (Node.js の Type Stripping を使う)
  • bundle や minify はしない
    • bundle や minify はブラウザが*.jsを効率的に fetch するための仕組みなので
    • 今回は*.jsを実行するのはブラウザではなく Node.js なので、全く不要

この場合、以下のような設定にする。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    // "moduleResolution": "nodenext",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
  }
}

解説

  • 実行環境が Node.js なので...
    • "module": "NodeNext"
    • "moduleResolution": "nodenext"
      • "module": "NodeNext"の時は自動で設定されるので省略可
  • tsc でトランスパイルしないので...
    • "noEmit": true
    • "target": "ESNext"
  • TS を.ts拡張子付きで import できるように
    • "allowImportingTsExtensions": true
    • "moduleResolution": "nodenext"の時、デフォルトだと import xxx from './collection.ts'がコンパイルエラーになる
      • 許可するにはallowImportingTsExtensionsが必要
  • Node.js Type Stripping がサポートしてない構文を禁止するため...
    • "erasableSyntaxOnly": true
    • TypeScript は JavaScript に型注釈を追加しただけの言語...なのだけど、その範疇を超えた構文がいくつかある
      • enum
      • namespace
      • Parameter Properties
      • ...
      • これらの構文は、TypeScript 公式のコンパイラ (tsc) はサポートされているが、Node.js の Type Stripping ではサポートされていない
      • そこで erasableSyntaxOnlyを付けて、これらの構文を禁止するべき

例 3: npm package 向けの構成

以下のような構成のプロジェクトのこと。

  • bundle はしない
  • tsc でトランスパイル (TS => JS)
  • 成果物を Node.js で実行
  • ソースコードは src/ に、成果物は dist/ にある

この場合、以下のような設定にする。

{
  "compilerOptions": {
    "target": "ES2021",
    "module": "NodeNext",
    // "moduleResolution": "nodenext",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist"
  }
}

解説

  • 各種オプションを Node.js 向けの推奨値に
    • "module": "NodeNext"
    • "moduleResolution": "nodenext"
      • "module": "NodeNext"の時は自動で設定されるので省略可
  • Node.js 向けに downlevel の設定を適切に行う
    • 2025/3 時点では"target": "ES2021"が良い
  • 型定義や sourcemap を出力するオプションを有効化
    • "declaration": true
    • "declarationMap": true
    • "sourceMap": true
  • 成果物の出力先のディレクトリ、ディレクトリの構成などを指定
    • "rootDir": "src"
    • "outDir": "dist"
    • 好きな値にカスタマイズできるけど、↑ が一般的

4. コンパイル時のチェックを厳しくする

いくつかのオプションで、コンパイル時のチェックを厳しくできる。気軽に導入できるものから、厳しすぎて導入が躊躇されがちなものまであるので、好みに応じて入れると良い。

とはいっても迷うと思うので、id:mizdra がよく入れてるものを書いておく。

{
  "compilerOptions": {
    "checkJs": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "allowUnusedLabels": false,
    "allowUnreachableCode": false
  }
}

解説

  • checkJs
    • *.jsも型チェックする
  • noUncheckedIndexedAccess
    • numbers[0]numberではなくnumber | undefinedにする
  • noImplicitReturns
    • 誤って void を返すのを防ぐ (ref)
  • noFallthroughCasesInSwitch
    • switch 文の case 句で break 省略禁止
  • allowUnusedLabels: false
    • 未使用のラベルを禁止
  • allowUnreachableCode: false
    • 到達不能なコードを禁止

逆に、以下は厳しすぎて基本的に使ってない。

  • noUnusedLocals
    • 警告を無視しようと思うと// @ts-ignore or // @ts-expect-errorするしかない
      • 他のnoUnusedLocals以外のコンパイルエラーも無視されてしまい、不便
    • 代わりに eslint の no-unused-vars を使ってる
  • noUnusedParameters
    • 同上
  • exactOptionalPropertyTypes
    • npm package 作る時だけ一応付けてる
    • Web アプリケーション作る時は厳しすぎて付けない

これで全てのステップが完了した。皆さんの手元にモダンな tsconfig.json ができているはず。

tsconfig-generator for @mizdra

記事冒頭でもしたけど、ボタンポチポチで tsconfig.json を生成するツールを作った。よければ使ってください。

一応 id:mizdra 専用ツールという建付けなので、オプションの変更や追加の提案は基本的に reject するつもり。カスタマイズしたければ fork してください。

おまけ

  • 実はステップ 3 で解説した内容と同じことが、 TypeScript/Node.js のドキュメントに書いてある
  • node scripts/build.ts/node scripts/migrate-db.ts で実行する script 向けの設定は?
  • eslint.config.ts/vite.config.tsなどのファイル向けの設定は?
  • includeオプションは何指定したら良い?
  • 型チェックを高速化するには?
    • "incremental": trueでキャッシュを有効化しよう
  • テストコード / アプリケーションコードで設定を分ける方法
  • モノレポで workspace ごとに設定を分けるには?
    • workspace ごとに tsconfig.json配置したら良い
    • 例: packages/pkg-a/tsconfig.json, packages/pkg-b/tsconfig.json, ...
  • pkg-bからimport {...} from 'pkg-a'すると、pkg-a をビルドしてない時に型チェック通らないんだけど...?
    • pkg-a が bundle をせず、tsc でトランスパイルする構成なら、TypeScript Project Reference が使える
    • pkg-a の tsconfig.json に "composite": true を、pkg-b の tsconfig.json に "references": [{"path": "../pkg-a/tsconfig.json"}] を追加すれば OK
      • これで pkg-b を tsc で型チェックする時に、自動で pkg-a をビルドしてから型チェックが走るようになる
      • pkg-a をビルドしてない状態でエディタで import {...} from 'pkg-a' を含むファイルを開いたときも、赤線が出ない
        • tsserver (TypeScript の Language Server) がいい感じに pkg-a のソースファイルを参照してくれてる
  • Bundler を使わずにブラウザで実行するプロジェクトで moduleResolution の値は何にすべき?
  • faux-ESM (syntax は ESM のものを使うけど CJS にトランスパイルされるモジュール) が書けないのだけど?
    • "verbatimModuleSyntax": true による制約で書けないようになってる
    • 以下のどっちかの対応をすると良い
        1. "verbatimModuleSyntax": true を消して "isolatedModules": true を付ける
        1. import ... from '...'import ... = require(...) に書き換える
    • import ... = require(...) 書くの嫌だから、id:mizdra は 1 の方法でやってる
    • 一方でそもそも faux-ESM 書かずに済むならそれが良いので、faux-ESM ではなく ESM で書けないかをまず検討すること

変更履歴

  • 2025/09/11: allowUnusedLabelsallowUnreachableCode の推奨値が誤って true になっているのを修正しました。正しくは false でした。

*1:2025/3/31 時点で https://www.typescriptlang.org/tsconfig/ に掲載されていたオプションの数より。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.