Next.js TypeScript 設定の応用:tsconfig 最適化と型安全の実践
テストレポートに刺さる赤い一行——「Production Error: Cannot read property ‘id’ of undefined」。ユーザーからはマイページを開くと真っ白になる、という報告でした。コードを辿ると、ルートが /user/profile ではなく /users/profile と書かれていた。余計な s が 1 つ。TypeScript は何も言わず、IDE もエラーを出さず、そのまま本番に載ってしまいました。
こういう「初歩的ミス」でしょうか。確かにそうです。でも、私が守っているプロジェクトでは、この手のミスが異常なほど頻繁に出ます。ルートのスペルミス、環境変数名の打ち間違い、関数引数がすべて any…… TypeScript は「型安全」と謳っているのに、使っていて JavaScript と大差ないと感じることがあります。
後から気づいたのですが、TypeScript が悪いのではなく、設定がかなり雑だったからでした。tsconfig.json の項目は多いのに、何をオン/オフすべきか迷う。ネットの記事も言うことがバラバラで、「strict にすると開発負担が増える」派と「strict なしは TypeScript を使う意味がない」派がいます。Next.js + TypeScript を約 1 年やっても、プロジェクト内の any はまだあちこちに残っていました。
この記事では、その 1 年で踏んだ坑のあとにまとめた話をします。tsconfig の最適化から、型安全なルーティング、環境変数の型定義まで、TypeScript を「足かせ」から「守り神」に変える実践的な設定を順に紹介します。難しい理論より、明日から使える内容に絞ります。
tsconfig 最適化設定 — 基礎を固める
strict モードの本当の意味
多くの人(以前の私も)は、strict: true を単なるスイッチだと思っています。オンにすれば TypeScript が厳しくなる、というイメージです。実際はそうではありません。
TypeScript 公式ドキュメントを開くと、strict は 7 つのコンパイラオプションのショートカットだとわかります。
{
"compilerOptions": {
"strict": true,
// 以下の 7 つがすべて true になるのと等価
"strictNullChecks": true, // 厳格な null チェック
"strictFunctionTypes": true, // 厳格な関数型チェック
"strictBindCallApply": true, // 厳格な bind/call/apply チェック
"strictPropertyInitialization": true, // 厳格なプロパティ初期化
"noImplicitAny": true, // 暗黙の any を禁止
"noImplicitThis": true, // 暗黙の this を禁止
"alwaysStrict": true // 常に strict モードで解析
}
}
特に役立つのは最初の 3 つです。まず strictNullChecks——これをオンにすると、TypeScript は null と undefined を「どんな型でも入れられる値」ではなく、独立した型として扱います。
例を挙げます。データベースからユーザー情報を取得する場合:
// strictNullChecks オフ
const user = await db.user.findOne({ id: userId })
console.log(user.name) // TypeScript はエラーを出さないが、user は null の可能性がある
// オンにした場合
const user = await db.user.findOne({ id: userId })
console.log(user.name) // ❌ エラー:オブジェクトは 'null' の可能性があります
// こう書く必要がある
if (user) {
console.log(user.name) // ✅ OK
}
古いプロジェクトで初めてこのオプションをオンにしたとき、IDE が一瞬で 200 本以上の赤い波線を出しました。パニックになり、危うく元に戻すところでした。冷静に見ると、これらは潜在的なバグ——null チェックをしていない箇所で、本番で本当に落ちる場所でした。
noImplicitAny も重要です。関数の引数や変数が「暗黙的に」any になることを禁止します。
// noImplicitAny オフ
function handleData(data) { // data は自動的に any
return data.value // どんな操作もエラーにならない
}
// オンにした場合
function handleData(data) { // ❌ エラー:パラメータに暗黙の any 型が含まれます
return data.value
}
// 明示的な型注釈が必要
function handleData(data: { value: string }) { // ✅
return data.value
}
最初は面倒に感じるでしょう。以前は関数をさっと書けましたが、今は型を定義しなければなりません。しばらく使うと、IDE の補完が賢くなることに気づきます。data. と打った瞬間にプロパティが並び、ドキュメントを探しに行く回数が減ります。
Next.js 特有の TypeScript 設定
Next.js プロジェクトの tsconfig.json には、いくつか特別な設定があります。私が今使っているベストプラクティス版を共有します。
{
"compilerOptions": {
// 基本設定
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "bundler",
// Next.js 必須
"allowJs": true,
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
// 厳格モード(コア)
"strict": true,
"skipLibCheck": true,
// パフォーマンス最適化
"incremental": true,
// Next.js プラグイン
"plugins": [
{
"name": "next"
}
],
// パスエイリアス
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/styles/*": ["./src/styles/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}
見落としがちなポイントをいくつか挙げます。
1. incremental:増分コンパイル
このオプションは大規模プロジェクトのコンパイルを大幅に速くします。オンにすると、TypeScript は前回のコンパイル情報をキャッシュし、次回は変更されたファイルだけをコンパイルします。300 以上のコンポーネントがあるプロジェクトでは、コンパイル時間が 45 秒から約 18 秒まで短縮されました。効果ははっきり出ます。
2. paths:パスエイリアス
以前の import はこんな感じでした。
import Button from '../../../components/ui/Button'
import { formatDate } from '../../../../lib/utils'
.. の数を数えるのも面倒で、フォルダを少し動かすだけですべて壊れます。
エイリアスを設定したあと:
import Button from '@/components/ui/Button'
import { formatDate } from '@/lib/utils'
すっきりします。TypeScript は型を正しく推論し、IDE のジャンプも使えます。
3. plugins:Next.js プラグイン
"plugins": [{ "name": "next" }] は単純に見えますが、TypeScript が Next.js 特有の概念——app 配下の layout.tsx や page.tsx の型、サーバーコンポーネントとクライアントコンポーネントの区別など——を理解できるようにします。
プラグインがないと、サーバーコンポーネントを書くときに意味不明な型エラーが出ることがあります。
段階的に strict を有効にする
プロジェクトがすでに大きくなっていると、いきなり strict: true はつらいです。無理をしないのがコツです。
戦略 1:新しいコードは strict、古いコードはゆっくり直す
tsconfig.json では strict: true を維持しつつ、すぐ直せない古いファイルの先頭に以下を追加します。
// @ts-nocheck // ファイル全体の型チェックをスキップ
特定の行だけなら:
// @ts-ignore // 次の行の型エラーを無視
ただし @ts-ignore と @ts-expect-error には違いがあります。
// @ts-ignore
const x = 1 as any // 次の行にエラーがなくても文句を言わない
// @ts-expect-error
const y = 1 // 次の行にエラーがないと「不要なコメント」と警告される
私は @ts-expect-error を推奨します。バグを直したあと、コメントを消し忘れたときに TypeScript が教えてくれるからです。
戦略 2:機能モジュールごとに段階的にオンにする
まず components 配下だけをきれいにし、他は一時的に緩くする、というやり方です。
// tsconfig.strict.json(厳格モード)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src/components/**/*"]
}
普段は通常の tsconfig.json、あるモジュールをリファクタリングするときだけ strict 版に切り替えます。
厳格モードは人をいじめるためのものではありません。古いコンポーネントを直したとき、strictNullChecks で null チェック漏れが 5 箇所見つかり、そのうち 3 箇所は本番で既にエラーになっていたのに try-catch に握りつぶされていた、という経験があります。そのとき、赤い波線が愛おしく感じました。
型安全なルーティング — スペルミスとの決別
Next.js 内蔵の Typed Routes
冒頭の本番バグを覚えていますか。ルートに s を 1 つ足して 404 になった件です。この種のエラーは防げます。
Next.js 13 は typedRoutes という実験的機能を導入しました。オンにすると、TypeScript がすべてのルートの型定義を生成します。
有効化する方法
next.config.ts に 1 行追加します。
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
typedRoutes: true, // 型安全なルートを有効化
},
}
export default nextConfig
開発サーバーを再起動(npm run dev)すると、Next.js が app をスキャンし、.next/types にルート型を生成します。
どんな効果があるか
プロジェクト構造が次のような場合:
app/
├── page.tsx // トップ
├── blog/
│ ├── page.tsx // ブログ一覧
│ └── [slug]/
│ └── page.tsx // 記事詳細
└── user/
└── [id]/
└── profile/
└── page.tsx // マイページ
typedRoutes を有効にすると、Link や useRouter でルートを書くとき IDE が補完してくれます。
import Link from 'next/link'
export default function Nav() {
return (
<nav>
<Link href="/">トップ</Link>
<Link href="/blog">ブログ</Link>
<Link href="/blog/hello-world">記事詳細</Link>
<Link href="/user/123/profile">マイページ</Link>
{/* ❌ エラー:ルートが存在しません */}
<Link href="/users/123/profile" /> // user ではなく users
</nav>
)
}
href="/ と入力すると、使えるルートがポップアップします。間違えればすぐ赤くなります。
初めて体験したとき、心の声は「これは便利」でした。
制限事項
現時点ではいくつか制限があります。
- App Router のみ:
pagesディレクトリでは使えません - 動的ルートのパラメータは手動:
/blog/[slug]の slug は自分で結合する必要があります - クエリはチェックされない:
/user?tab=settingsのtabは型チェックされません
パス自体のスペルミスは防げますが、パラメータ値は自分で注意してください。
サードパーティ:nextjs-routes
pages を使っている、またはクエリまで型安全にしたい場合は nextjs-routes を試せます。
インストールと設定
npm install nextjs-routes
next.config.ts に追加:
const nextRoutes = require('nextjs-routes/config')
const nextConfig = nextRoutes({
// 既存の Next.js 設定
})
export default nextConfig
使い方
route 関数でオブジェクト形式のルートを定義できます。
import { route } from 'nextjs-routes'
const profileRoute = route({
pathname: '/user/[id]/profile',
query: {
id: '123',
tab: 'settings', // クエリも型チェック
}
})
router.push(profileRoute) // 型安全
const wrongRoute = route({
pathname: '/users/[id]/profile', // ❌ パスが存在しません
})
内蔵の typedRoutes と比べると、nextjs-routes は pages 対応、クエリの型チェック、オブジェクト形式のルート定義が利点です。欠点は依存関係の追加と、ルート構造変更時の型再生成(自動)です。
ルートパラメータの型推論
app/blog/[slug]/page.tsx の slug の型は?
Next.js は params の型を自動生成します。
// app/blog/[slug]/page.tsx
export default function BlogPost({
params,
}: {
params: { slug: string }
}) {
return <h1>記事:{params.slug}</h1>
}
ただし slug は単なる string で、どんな文字列でも入ります。もっと厳しくするなら zod でランタイム検証します。
import { z } from 'zod'
const slugSchema = z.string().regex(/^[a-z0-9-]+$/)
export default function BlogPost({
params,
}: {
params: { slug: string }
}) {
const validatedSlug = slugSchema.parse(params.slug)
return <h1>記事:{validatedSlug}</h1>
}
フォーマットに合わない slug(大文字や記号など)では zod がエラーを投げます。API ルートでは特に有効です。ユーザー入力は制御できないので、先に検証したほうが本番で爆発するよりマシです。
環境変数の型定義 — any を減らす
問題の根本
環境変数まわりは、TypeScript のデフォルトサポートが弱いです。
よくあるコード:
const apiKey = process.env.API_KEY
apiKey の型は string | undefined。undefined の可能性はわかります。
しかし実際によくあるのはこちらです。
const apiUrl = process.env.NEXT_PUBLIC_API_URL
console.log(apiUrl.toUpperCase()) // 実行時エラー:apiUrl is undefined
TypeScript は黙っていて、実行して初めて未設定だとわかります。
変数名のスペルミスも検知しません。
const key = process.env.API_SECRE // T が 1 つ足りない
// TypeScript:問題なし、string | undefined です
TypeScript を使っているのに、結局は目視で変数名を確認する——JavaScript と何が違うのでしょうか。
T3 Env の利用(推奨)
いまコミュニティでよく使われるのが T3 Env です。型チェックとランタイム検証の両方を提供します。
インストール
npm install @t3-oss/env-nextjs zod
設定
プロジェクトルートに env.mjs(または env.ts)を作成:
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
export const env = createEnv({
// サーバー側(クライアントからはアクセス不可)
server: {
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(32),
SMTP_HOST: z.string().min(1),
},
// クライアント側(NEXT_PUBLIC_ で始める必要あり)
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_ANALYTICS_ID: z.string().optional(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
API_SECRET: process.env.API_SECRET,
SMTP_HOST: process.env.SMTP_HOST,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
},
})
使用
import { env } from './env.mjs'
// ✅ 型安全、補完あり
const dbUrl = env.DATABASE_URL // string
const appUrl = env.NEXT_PUBLIC_APP_URL // string
// ❌ スペルミス
const wrong = env.DATABASE_UR
// ❌ クライアントからサーバー変数へアクセス不可
// クライアントコンポーネント内
'use client'
const secret = env.API_SECRET // コンパイルエラー
特に便利な点
- 起動時検証:欠落や形式エラーは実行時ではなく起動時にわかる
- 型推論:
string | undefinedではなく正確な型になる - 漏洩防止:クライアントがサーバー変数に触るとコンパイルエラー
T3 Env 導入前は、テスト環境で変数の設定忘れに気づくのにログを追うことが多かったです。今は起動時にわかるので時間を節約できています。
カスタム型宣言ファイル
T3 Env を入れたくない、小規模プロジェクトなら ProcessEnv を手動拡張できます。
// env.d.ts
namespace NodeJS {
interface ProcessEnv {
// サーバー側変数
DATABASE_URL: string
API_SECRET: string
SMTP_HOST: string
// クライアント側変数
NEXT_PUBLIC_APP_URL: string
NEXT_PUBLIC_ANALYTICS_ID?: string // 任意変数は ? を付ける
}
}
これで TypeScript は各変数の型を理解できます。
const dbUrl = process.env.DATABASE_URL // string
const apiSecret = process.env.API_SECRET // string
// ❌ TypeScript エラー
const wrong = process.env.DATABASE_UR // Property 'DATABASE_UR' does not exist
欠点
- ランタイム検証がなく、欠落は実行時までわからない
- クライアントからのサーバー変数アクセスを防げない
- 型定義を手動メンテする必要がある
小規模向けですが、TypeScript を使うなら T3 Env で一気に揃えるのがおすすめです。
TypeScript 厳格モードの実践
サードパーティの型問題
自分のコードではなく、ライブラリ側に型がない/壊れている場合があります。
ケース 1:型定義がまったくない
import oldLib from 'some-old-lib' // any
まず @types/some-old-lib を探します。
npm install -D @types/some-old-lib
なければ types/some-old-lib.d.ts を自作します。
declare module 'some-old-lib' {
export function doSomething(param: string): number
export default someOldLib
}
これで TypeScript はこのライブラリの型を理解できます。
ケース 2:型定義が実 API とずれている
@types パッケージの型定義が実際の API と合わないことがあります。特に更新の速いライブラリで起きがちです。この場合は一時的に型アサーションで凌ぎます。
import { someFunction } from 'buggy-lib'
// 型定義では string を返すが、実際は number を返す
const result = someFunction() as number
恒久対応はライブラリの GitHub に issue や PR を出すことです。
skipLibCheck はオンにすべき?
tsconfig には skipLibCheck というオプションがあります。オンにすると TypeScript は node_modules 内の型チェックをスキップします。
私のおすすめは、オンにすることです。
理由はシンプルです。node_modules の型エラーは直せず、コンパイルも遅くなるだけです。TypeScript に大量のサードパーティ型を検査させるより、自分のコードに集中しましょう。
よくある any の逃げ道と対策
strict でも any に「逃げる」場所があります。
シナリオ 1:イベントハンドラ
// ❌ よくない書き方
const handleSubmit = (e: any) => {
e.preventDefault()
}
// ✅ 正しい書き方
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// e.currentTarget に完全な型ヒントが付く
}
よく使うイベント型:
React.MouseEvent<HTMLButtonElement>React.ChangeEvent<HTMLInputElement>React.KeyboardEvent<HTMLDivElement>
シナリオ 2:API レスポンス
// ❌ よくない書き方
const res = await fetch('/api/user')
const data = await res.json() // any
// ✅ 方法 1:手動でインターフェースを定義
interface User { id: string; name: string; email: string }
const data: User = await res.json()
// ✅ 方法 2:zod で検証(推奨)
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
const data = UserSchema.parse(await res.json()) // 型を自動推論
zod なら型チェックとランタイム検証の両方が効き、バックエンドの変更にもすぐ気づけます。
シナリオ 3:動的 import
// ❌ よくない書き方
const module = await import('./utils') // any
// ✅ 正しい書き方
const module = await import('./utils') as typeof import('./utils')
または、具体的な import を直接使います。
const { formatDate } = await import('./utils')
ユーティリティ型で効率化
TypeScript には便利なユーティリティ型が多数あります。使いこなすとコード量をかなり減らせます。
Pick
interface User {
id: string
name: string
email: string
password: string
createdAt: Date
}
// ユーザーの公開情報だけが必要
type PublicUser = Pick<User, 'id' | 'name' | 'email'>
// { id: string; name: string; email: string }
Omit
// ユーザー作成時には id と createdAt は不要
type CreateUserInput = Omit<User, 'id' | 'createdAt'>
Partial:すべてのプロパティを任意にする
// ユーザー更新時はすべてのフィールドが任意
type UpdateUserInput = Partial<User>
Required:すべてのプロパティを必須にする
type RequiredUser = Required<Partial<User>>
カスタムユーティリティ型
// すべての文字列プロパティを任意にする
type PartialString<T> = {
[K in keyof T]: T[K] extends string ? T[K] | undefined : T[K]
}
最初は難しく見えますが、複雑なオブジェクト型では重複コードをかなり減らせます。
まとめ
冒頭の深夜のバグを思い出します。
typedRoutes があればルートのスペルミスは本番に出ません。T3 Env があれば環境変数の欠落は起動時にわかります。strict が整っていれば暗黙の any は IDE が先に摘発します。
型安全は人をいじめるためではなく、バグを「実行時」から「記述時」に前倒しするためのものです。ユーザーに白画面を見せるより、コードを書いているうちに赤くしてもらうほうがマシです。
要点の整理:
- tsconfig:strict、incremental、paths、Next.js プラグイン
- 型安全ルーティング:typedRoutes または nextjs-routes
- 環境変数:T3 Env で型 + ランタイム検証
- 厳格モードの運用:段階的導入、サードパーティ型、any の逃げ道を潰す
最初は設定も型注釈も面倒に感じるでしょう。IDE の補完に慣れ、修正のたびに潜在バグが見つかる体験に慣れると、「裸」の JavaScript には戻れなくなります。
今すぐ tsconfig.json を開き、strict を true にしてみてください。赤い波線が多いほど、見つかった潜在バグも多い——それは良いことです。
FAQ
strict モードはプロジェクトのコンパイルを遅くしますか?
既存プロジェクトで安全に strict を有効にするには?
T3 Env と ProcessEnv を手動で型定義する違いは?
Next.js の typedRoutes は pages ディレクトリに対応していますか?
skipLibCheck をオンにするとセキュリティ上の問題はありますか?
4分で読めます · 公開日: 2026年1月6日 · 更新日: 2026年6月8日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js 状態管理選定ガイド: Zustand vs Jotai 実践比較
Redux は重すぎる、Context はパフォーマンスが悪い? Zustand と Jotai を Next.js で実際に比較し、明確な選定ガイドと App Router のベストプラクティスを提供。あなたに合った軽量な状態管理ソリューション選びをサポートします。
第 26 / 47 記事
次の記事
Next.js エンジニアリング設定:ESLint + Prettier + Husky オールインワン構築ガイド
金曜の夜にフォーマットの問題でPRが却下されましたか?チームのコードスタイルがバラバラで無意味な競合が発生していませんか?この記事では、ESLint、Prettier、Huskyを設定し、コードの自動チェックとフォーマットを実現して、チームのコラボレーションを効率化する方法をステップバイステップで説明します。
第 28 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます