tRPC が型推論しない時の対処:as const・型循環・import サイクルの解消
tRPC を使っていると、突然型推論が効かなくなって困った経験はありませんか?
エディタで any 型が表示されたり、補完が効かなくなったりすると、せっかくの型安全性が台無しになってしまいますよね。実は、tRPC の型推論が動かなくなる原因は主に 3 つあります。それは as const の不足、型循環、そして import サイクルです。
本記事では、tRPC の型推論が効かなくなる具体的な原因と、それぞれの解決策を段階的に解説していきます。エラーコードとともに実践的な対処法をお伝えしますので、トラブルシュートの際にすぐに活用できる内容になっています。
背景
tRPC の型推論メカニズム
tRPC は TypeScript の強力な型推論機能を活かして、サーバー側の API 定義からクライアント側の型を自動生成します。
この仕組みにより、API の引数や返り値の型が自動的にクライアントに伝わり、エディタで補完やエラーチェックが効くようになるのです。しかし、この型推論は TypeScript コンパイラの機能に依存しているため、特定の条件下では正しく動作しなくなります。
型推論が動作する前提条件
tRPC の型推論が正しく機能するためには、以下の条件を満たす必要があります。
typescript// サーバー側のルーター定義
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
// 型推論が効くためには、ルーターの型が正確に決定される必要がある
export const appRouter = t.router({
hello: t.procedure.query(() => 'Hello World'),
});
// この型エクスポートが型推論の起点となる
export type AppRouter = typeof appRouter;
上記のコードでは、appRouter の型を typeof で抽出し、AppRouter として公開しています。
クライアント側では、この AppRouter 型を使って tRPC クライアントを初期化することで、すべてのプロシージャの型情報が利用可能になります。この型の流れが一箇所でも途切れると、型推論は失敗してしまうのです。
以下の図は、tRPC における型情報の流れを示したものです。
mermaidflowchart TB
server["サーバー側<br/>appRouter 定義"] -->|typeof で型抽出| exportType["AppRouter 型<br/>エクスポート"]
exportType -->|import| client["クライアント側<br/>tRPC クライアント"]
client -->|型推論| hooks["useQuery / useMutation<br/>自動補完・型チェック"]
breakPoint1["❌ 型循環"] -.->|型が決定できない| exportType
breakPoint2["❌ import サイクル"] -.->|モジュール解決失敗| exportType
breakPoint3["❌ as const 不足"] -.->|widening で型が汎化| exportType
図の要点:サーバーからクライアントへの型の流れは一方向ですが、型循環や import サイクルなどの問題により、型エクスポートの段階で情報が失われてしまいます。
課題
型推論が効かなくなる 3 つの主要原因
tRPC の型推論が動作しなくなる原因は、大きく分けて以下の 3 つに分類できます。
それぞれの原因は異なるメカニズムで型情報を破壊するため、適切に診断して対処する必要があります。実際の開発現場では、これらの問題が複合的に発生することも少なくありません。
| # | 原因 | 症状 | 重要度 |
|---|---|---|---|
| 1 | as const の不足 | 型が string や number に汎化 | ★★★ |
| 2 | 型循環(Circular Type Reference) | Type instantiation is excessively deep エラー | ★★★★★ |
| 3 | import サイクル(Circular Dependency) | 型が any になる、または undefined | ★★★★ |
原因 1:as const の不足による型の汎化
TypeScript では、オブジェクトリテラルや配列リテラルの型は、デフォルトで汎化(widening)されます。
例えば、'user' という文字列リテラルは string 型に、10 という数値リテラルは number 型に自動的に広げられてしまいます。tRPC では、プロシージャ名やステータスコードなど、リテラル型として保持すべき値が多数存在します。
typescript// ❌ 悪い例:型が汎化される
const userStatus = {
active: 'active',
inactive: 'inactive',
};
// userStatus の型: { active: string; inactive: string }
このような汎化により、tRPC は正確な型情報を失い、クライアント側での補完やエラーチェックが機能しなくなります。
原因 2:型循環による型解決の失敗
型循環とは、型定義が互いに参照し合うことで、TypeScript コンパイラが型を確定できなくなる状態を指します。
typescript// ❌ 型循環の例
type User = {
id: string;
posts: Post[]; // Post を参照
};
type Post = {
id: string;
author: User; // User を参照
};
tRPC では、ルーターの定義が複雑になると、意図せず型循環が発生することがあります。
特に、プロシージャ間で相互に型を参照している場合や、context の型定義が再帰的になっている場合に発生しやすいです。TypeScript は型の展開に深さ制限があり、循環参照があると以下のエラーが発生します。
plaintextError TS2589: Type instantiation is excessively deep and possibly infinite.
このエラーが発生すると、型推論は完全に停止し、any 型にフォールバックされてしまいます。
原因 3:import サイクルによるモジュール解決の失敗
import サイクルは、モジュール間の依存関係が循環している状態です。
mermaidflowchart LR
moduleA["router.ts"] -->|import| moduleB["procedures.ts"]
moduleB -->|import| moduleC["types.ts"]
moduleC -->|import| moduleA
style moduleA fill:#ff6b6b
style moduleB fill:#ff6b6b
style moduleC fill:#ff6b6b
図の要点:A → B → C → A という循環依存が発生すると、モジュールの初期化順序が不定となり、型が正しく読み込まれません。
JavaScript のモジュールシステムでは、import サイクルが存在すると、モジュールの初期化順序が不確定になります。
その結果、型定義が読み込まれる前にアクセスされ、undefined や any になってしまうのです。特に tRPC では、ルーター定義とプロシージャ定義を分割する際に、意図せず import サイクルを作ってしまうことがあります。
解決策
解決策 1:as const による型の固定
as const アサーションを使用することで、TypeScript に対して「この値は変更されない」ことを明示し、型の汎化を防ぐことができます。
tRPC では、定数オブジェクトや配列に as const を付けることが推奨されます。これにより、リテラル型が保持され、型推論が正確に動作するようになります。
定数定義での as const
typescript// ✅ 良い例:as const で型を固定
const userStatus = {
active: 'active',
inactive: 'inactive',
} as const;
// userStatus の型: { readonly active: "active"; readonly inactive: "inactive" }
上記のように as const を付けることで、'active' という文字列リテラル型が保持されます。
これにより、tRPC のプロシージャで使用した際に、正確な型チェックと補完が可能になります。
Zod スキーマでの as const
Zod を使ったバリデーションスキーマでも、as const は重要な役割を果たします。
typescriptimport { z } from 'zod';
// ❌ 悪い例:enum 値が string[] 型になる
const userRoles = ['admin', 'user', 'guest'];
const UserRoleSchema = z.enum(userRoles); // エラー発生
上記のコードでは、userRoles が string[] 型と推論されるため、Zod の z.enum() に渡すことができません。
typescript// ✅ 良い例:as const で readonly タプル型にする
const userRoles = ['admin', 'user', 'guest'] as const;
const UserRoleSchema = z.enum(userRoles); // 正常に動作
// 型: z.ZodEnum<["admin", "user", "guest"]>
as const を使うことで、配列が readonly ["admin", "user", "guest"] という厳密なタプル型になり、Zod が正しく型を推論できるようになります。
解決策 2:型循環の解消テクニック
型循環を解消するには、循環参照を断ち切る必要があります。主な手法は以下の 3 つです。
手法 A:型の分離とインターフェースの活用
循環参照している型を分離し、インターフェースを使って段階的に定義します。
typescript// ✅ インターフェースで型を定義
interface User {
id: string;
name: string;
posts: Post[];
}
interface Post {
id: string;
title: string;
authorId: string; // User への直接参照を避ける
}
上記のように、Post 型では User 全体を参照せず、authorId という ID だけを持たせることで循環を回避しています。
必要に応じて、クライアント側で User と Post を結合する処理を行います。
手法 B:Omit / Pick による部分型の利用
既存の型から必要な部分だけを抽出することで、循環参照を避けられます。
typescripttype User = {
id: string;
name: string;
email: string;
posts: Post[];
};
// Post では User の一部だけを参照
type Post = {
id: string;
title: string;
author: Pick<User, 'id' | 'name'>; // email や posts は除外
};
Pick や Omit を使うことで、型の依存関係を限定的にし、循環を断ち切ることができます。
この手法は、既存のコードを大きく変更せずに型循環を解消できるメリットがあります。
手法 C:type と interface の混在回避
TypeScript では、type と interface は微妙に動作が異なります。
特に循環参照の解決において、interface の方が柔軟に対応できるケースがあります。混在させると、予期しない型エラーが発生することがあるため、プロジェクト内で統一することを推奨します。
typescript// ✅ 統一例:すべて interface で定義
interface BaseUser {
id: string;
name: string;
}
interface User extends BaseUser {
posts: Post[];
}
interface Post {
id: string;
author: BaseUser; // User ではなく BaseUser を参照
}
上記のように、基底となる型を定義し、それを継承する形にすることで、循環を避けつつ型の再利用性を高められます。
解決策 3:import サイクルの検出と解消
import サイクルを解消するには、まず依存関係を可視化し、循環を断ち切る設計に変更する必要があります。
ESLint による自動検出
ESLint のプラグインを使うことで、import サイクルを自動検出できます。
bashyarn add -D eslint-plugin-import
上記のコマンドで、eslint-plugin-import をインストールします。
このプラグインは、モジュール間の依存関係を解析し、循環参照を警告してくれます。
json{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 10 }]
}
}
.eslintrc.json に上記の設定を追加することで、import サイクルがあればビルド時にエラーが表示されます。
maxDepth は、何階層先まで依存関係をチェックするかを指定するオプションです。
ファイル構成の見直し
import サイクルの多くは、ファイル分割が不適切なために発生します。
tRPC では、以下のようなディレクトリ構成を推奨します。
plaintextsrc/
├── server/
│ ├── trpc.ts # tRPC 初期化
│ ├── context.ts # Context 定義(他に依存しない)
│ ├── routers/
│ │ ├── _app.ts # ルートルーター
│ │ ├── user.ts # ユーザー関連プロシージャ
│ │ └── post.ts # 投稿関連プロシージャ
│ └── types/ # 共通型定義(他に依存しない)
│ └── index.ts
上記の構成では、依存関係が一方向になるように設計されています。
context.ts と types/ は最下層に配置し、他のモジュールから import されるだけで、逆方向の import は行いません。
Barrel Export の活用と注意点
Barrel Export(index.ts で再エクスポート)は便利ですが、使い方を誤ると import サイクルの原因になります。
typescript// ❌ 悪い例:循環参照を生む Barrel Export
// src/server/routers/index.ts
export * from './user';
export * from './post';
// src/server/routers/user.ts
import { postRouter } from './index'; // 循環発生
上記のように、Barrel Export を経由して相互 import すると、循環参照が発生します。
typescript// ✅ 良い例:直接 import する
// src/server/routers/user.ts
import { postRouter } from './post'; // 直接 import
Barrel Export は外部公開用にのみ使用し、内部モジュール間では直接 import することを推奨します。
この原則を守ることで、import サイクルのリスクを大幅に減らせます。
具体例
ケーススタディ 1:as const 不足によるエラー
実際のプロジェクトで発生した、as const 不足による型推論エラーの例を見ていきます。
エラー発生コード
typescript// サーバー側のプロシージャ定義
import { z } from 'zod';
import { t } from './trpc';
const postStatus = ['draft', 'published', 'archived'];
export const postRouter = t.router({
create: t.procedure
.input(
z.object({
title: z.string(),
status: z.enum(postStatus), // ❌ エラー発生
})
)
.mutation(({ input }) => {
return { id: 1, ...input };
}),
});
上記のコードを実行すると、以下のエラーが発生します。
plaintextError TS2345: Argument of type 'string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.
Target requires 1 element(s) but source may have fewer.
エラーコード: TS2345
発生条件: z.enum() に通常の配列を渡すと、TypeScript は string[] 型と推論します。しかし、z.enum() は readonly [string, ...string[]](少なくとも 1 要素を持つ読み取り専用タプル)を期待しているため、型の不一致が起こります。
解決コード
typescript// ✅ as const を追加
const postStatus = [
'draft',
'published',
'archived',
] as const;
export const postRouter = t.router({
create: t.procedure
.input(
z.object({
title: z.string(),
status: z.enum(postStatus), // 正常に動作
})
)
.mutation(({ input }) => {
return { id: 1, ...input };
}),
});
as const を追加することで、配列が readonly ["draft", "published", "archived"] というタプル型になります。
これにより、Zod が期待する型と一致し、エラーが解消されます。さらに、クライアント側でも status の型が正確に推論されるようになります。
ケーススタディ 2:型循環によるエラー
複雑なルーター構成で発生した型循環エラーの事例です。
エラー発生コード
typescript// types.ts
import type { AppRouter } from './router';
export type User = {
id: string;
name: string;
router: AppRouter; // ❌ ルーター型を参照
};
typescript// router.ts
import { t } from './trpc';
import type { User } from './types';
export const appRouter = t.router({
getUser: t.procedure.query((): User => {
// ... User 型を返す
}),
});
export type AppRouter = typeof appRouter; // ❌ 型循環発生
上記のコードでは、User 型が AppRouter を参照し、AppRouter が User を参照しているため、型循環が発生します。
plaintextError TS2589: Type instantiation is excessively deep and possibly infinite.
エラーコード: TS2589
発生条件: 型定義が相互に参照し合い、TypeScript コンパイラが型の展開を無限に繰り返そうとすると、深さ制限に達してこのエラーが発生します。
解決コード:型の分離
typescript// types.ts(循環参照を削除)
export type User = {
id: string;
name: string;
// router への参照を削除
};
typescript// router.ts
import { t } from './trpc';
import type { User } from './types';
export const appRouter = t.router({
getUser: t.procedure.query((): User => {
return {
id: '1',
name: 'John Doe',
};
}),
});
export type AppRouter = typeof appRouter; // ✅ 正常に動作
型循環を解消するため、User 型から AppRouter への参照を削除しました。
必要であれば、クライアント側で別途ルーター情報を管理する設計に変更します。型の依存関係を一方向に保つことが、型循環を防ぐ鍵となります。
ケーススタディ 3:import サイクルによる型の消失
モジュール分割時に発生した import サイクルの実例です。
エラー発生コード
typescript// routers/user.ts
import { t } from '../trpc';
import { postRouter } from './post';
export const userRouter = t.router({
list: t.procedure.query(() => {
// postRouter を使用
}),
});
typescript// routers/post.ts
import { t } from '../trpc';
import { userRouter } from './user';
export const postRouter = t.router({
list: t.procedure.query(() => {
// userRouter を使用
}),
});
上記のコードでは、user.ts と post.ts が相互に import しており、import サイクルが発生しています。
この場合、エラーメッセージは出ないものの、型推論が any になってしまいます。
エラー検出結果
ESLint を実行すると、以下の警告が表示されます。
plaintextError: Dependency cycle detected:
routers/user.ts -> routers/post.ts -> routers/user.ts
エラーコード: import/no-cycle(ESLint ルール)
発生条件: モジュール A が B を import し、B が A を import すると、循環依存が発生します。
解決コード:ルーターの統合
typescript// routers/user.ts
import { t } from '../trpc';
// postRouter への依存を削除
export const userRouter = t.router({
list: t.procedure.query(() => {
// 独立した処理
}),
});
typescript// routers/post.ts
import { t } from '../trpc';
// userRouter への依存を削除
export const postRouter = t.router({
list: t.procedure.query(() => {
// 独立した処理
}),
});
typescript// routers/_app.ts(統合ポイント)
import { t } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = t.router({
user: userRouter,
post: postRouter,
});
export type AppRouter = typeof appRouter;
各ルーターを独立させ、最上位の _app.ts で統合することで、import サイクルを解消しました。
この設計パターンは、tRPC の公式ドキュメントでも推奨されており、スケーラビリティの高いアーキテクチャを実現できます。
ケーススタディ 4:複合的なエラーの解決
実際のプロジェクトでは、複数の問題が同時に発生することがあります。
以下は、as const 不足と import サイクルが同時に発生したケースです。
エラー発生コード
typescript// constants.ts
export const apiErrors = {
notFound: 'NOT_FOUND',
unauthorized: 'UNAUTHORIZED',
};
// apiErrors の型: { notFound: string; unauthorized: string }
typescript// routers/auth.ts
import { apiErrors } from '../constants';
import { userRouter } from './user';
export const authRouter = t.router({
login: t.procedure.mutation(() => {
throw new Error(apiErrors.unauthorized); // 型が string
}),
});
上記のコードでは、2 つの問題があります。
apiErrorsにas constがないため、エラーコードの型がstringに汎化されているauth.tsとuser.tsの間に import サイクルが存在する可能性
段階的解決手順
ステップ 1:as const を追加
typescript// constants.ts
export const apiErrors = {
notFound: 'NOT_FOUND',
unauthorized: 'UNAUTHORIZED',
} as const;
// 型: { readonly notFound: "NOT_FOUND"; readonly unauthorized: "UNAUTHORIZED" }
ステップ 2:ESLint で import サイクルをチェック
bashyarn eslint src/routers --ext .ts
ステップ 3:import サイクルを解消
typescript// routers/_app.ts
import { authRouter } from './auth';
import { userRouter } from './user';
export const appRouter = t.router({
auth: authRouter,
user: userRouter,
});
段階的に問題を切り分けることで、複合的なエラーも確実に解決できます。
まず型の問題を解消し、次にモジュール構造を見直すという順序で進めると、効率的にトラブルシュートできます。
まとめ
tRPC の型推論が効かなくなる主な原因は、as const の不足、型循環、import サイクルの 3 つです。
それぞれに適切な対処法があり、問題を正しく診断することが解決の第一歩となります。as const を使った型の固定は、最も頻繁に必要となる対処法であり、定数定義や Zod スキーマで積極的に活用しましょう。
型循環が発生した場合は、型の分離や部分型の利用によって依存関係を整理する必要があります。インターフェースの活用や Pick/Omit を使った型の限定的な参照が効果的です。
import サイクルは、ESLint で自動検出し、ファイル構成を見直すことで解消できます。依存関係を一方向に保つ設計を心がけ、Barrel Export は外部公開用にのみ使用することが重要ですね。
実際のプロジェクトでは、これらの問題が複合的に発生することもありますが、段階的に切り分けて対処することで、確実に型推論を復活させることができます。本記事で紹介したエラーコードや解決手順を参考に、tRPC の型安全性を最大限に活用していきましょう。
関連リンク
articletRPC が型推論しない時の対処:as const・型循環・import サイクルの解消
articletRPC と GraphQL 徹底比較:設計自由度・型安全・オーバーフェッチの実態
articletRPC 使い方入門:Todo API を 50 行で作るフルスタック体験
articletRPC アーキテクチャ設計:BFF とドメイン分割で肥大化を防ぐルータ戦略
articletRPC チートシート:Router/Procedure/ctx/useQuery/useMutation 早見表
articletRPC の始め方:Next.js App Router と Zod を使った最小構成テンプレート
articleWebSocket Close コード早見表:正常終了・プロトコル違反・ポリシー違反の実務対応
articleStorybook 品質ゲート運用:Lighthouse/A11y/ビジュアル差分を PR で自動承認
articleWebRTC で高精細 1080p/4K 画面共有:contentHint「detail」と DPI 最適化
articleSolidJS フォーム設計の最適解:コントロール vs アンコントロールドの棲み分け
articleWebLLM 使い方入門:チャット UI を 100 行で実装するハンズオン
articleShell Script と Ansible/Make/Taskfile の比較:小規模自動化の最適解を検証
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来