型安全で読みやすく、保守しやすい TypeScript コードを書くための実践的なガイドです。
目次
- コンパイラ設定(tsconfig)
- 型の基本方針
- any / unknown / never の使い分け
- 型定義:type と interface
- ユニオン型と型の絞り込み
- null / undefined の扱い
- 関数の設計
- ジェネリクス
- ユーティリティ型の活用
- イミュータビリティ
- enum の代替
- エラーハンドリング
- 非同期処理
- 外部データの検証
- モジュールとインポート
- クラスとオブジェクト指向
- 命名規則
- ツールと品質管理
- 避けるべきアンチパターン
- チェックリスト
1. コンパイラ設定(tsconfig)
strict: true は必須。 新規プロジェクトでは最初から有効にしましょう。後から有効にすると大量のエラー修正が必要になります。
{
"compilerOptions": {
// --- 型チェックの厳格化 ---
"strict": true, // strictNullChecks, noImplicitAny などを一括有効化
"noUncheckedIndexedAccess": true, // arr[i] / obj[key] を T | undefined として扱う
"exactOptionalPropertyTypes": true, // `prop?: T` に undefined を明示代入するのを禁止
"noImplicitOverride": true, // オーバーライド時に override キーワードを必須化
"noImplicitReturns": true, // すべてのコードパスで return を要求
"noFallthroughCasesInSwitch": true, // switch の fallthrough を禁止
"noPropertyAccessFromIndexSignature": true,
// --- モジュール ---
"module": "NodeNext", // バンドラ利用時は "ESNext" + "moduleResolution": "Bundler"
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true, // import type を強制し、出力を予測可能に
"isolatedModules": true, // esbuild / swc などの単一ファイル変換と互換に
"esModuleInterop": true,
"resolveJsonModule": true,
// --- 出力 ---
"target": "ES2022",
"sourceMap": true,
"declaration": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}💡 Node.js の型ストリップ機能などで「型を消すだけ」で実行する場合は
"erasableSyntaxOnly": trueを有効にすると、enumやnamespaceなどランタイムコードを生む構文を禁止できます。
2. 型の基本方針
型推論に任せられるところは任せる
// ❌ 冗長
const count: number = 0;
const names: string[] = ["a", "b"];
// ✅ 推論に任せる
const count = 0;
const names = ["a", "b"];境界(公開 API)には明示的に型を書く
関数の引数・戻り値、エクスポートする値には型注釈を付けます。意図しない型変更が外部に漏れるのを防げます。
// ✅ 公開関数の戻り値は明示する
export function parseUser(input: string): User {
// ...
}satisfies で「型チェック」と「推論の保持」を両立する
type Route = { path: string; auth: boolean };
// ❌ 型注釈だとキーの情報が失われる
const routes: Record<string, Route> = {
home: { path: "/", auth: false },
};
routes.typo; // エラーにならない
// ✅ satisfies なら型チェックしつつ具体的な型を保持
const routes = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
} satisfies Record<string, Route>;
routes.typo; // ❌ コンパイルエラーas const でリテラル型を保持する
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"3. any / unknown / never の使い分け
| 型 | 用途 | 方針 |
|---|---|---|
any | 型チェックを無効化 | 原則使わない。移行期の一時的な逃げ道のみ |
unknown | 型が分からない値 | 外部入力・catch の例外などに使い、絞り込んでから利用 |
never | 起こり得ない値 | 網羅性チェックや到達不能コードに使う |
// ❌ any は型安全性を破壊し、伝播する
function handle(data: any) {
data.foo.bar(); // 実行時エラーの可能性
}
// ✅ unknown は使う前に絞り込みが必要
function handle(data: unknown) {
if (typeof data === "object" && data !== null && "foo" in data) {
// ...
}
}型アサーション(as)は最小限に
as はコンパイラに「信じて」と言うだけで、実行時の保証はありません。型ガードやバリデーションで置き換えましょう。
// ❌
const user = JSON.parse(text) as User;
// ✅ 検証してから使う(→ 14章参照)
const user = UserSchema.parse(JSON.parse(text));非 null アサーション(!)も同様に、可能な限り避けます。
4. 型定義:type と interface
どちらでも多くのことができます。チーム内で一貫性を保つことが最重要です。
一般的な指針:
type— ユニオン型、交差型、マップ型、条件型、タプルなど、interfaceで表現できないものinterface— オブジェクトの形状、特に拡張(extends)や宣言マージが必要な公開 API
// type が必要なケース
type Status = "idle" | "loading" | "success" | "error";
type Pair<T> = [T, T];
// interface が適するケース
interface User {
id: string;
name: string;
}
interface Admin extends User {
permissions: string[];
}
interfaceのextendsは交差型(&)よりもエラーメッセージが分かりやすく、型チェックのパフォーマンスも良い傾向があります。
5. ユニオン型と型の絞り込み
判別可能なユニオン(Discriminated Union)を使う
状態を表すときは、オプショナルなプロパティを並べるのではなく、共通の判別キーを持つユニオンにします。
// ❌ 不正な状態が表現できてしまう(loading なのに data がある等)
type State = {
loading: boolean;
data?: User;
error?: Error;
};
// ✅ 不正な状態を型レベルで排除
type State =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };網羅性チェック(Exhaustiveness Check)
function render(state: State): string {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.name;
case "error":
return state.error.message;
default: {
const _exhaustive: never = state; // ケース追加漏れでコンパイルエラー
return _exhaustive;
}
}
}ユーザー定義型ガード
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string"
);
}6. null / undefined の扱い
strictNullChecks(strictに含まれる)を必ず有効に- オプショナルチェーン
?.と Null 合体演算子??を活用 ||ではなく??を使う(0や""を誤って置き換えないため)
// ❌ count が 0 のとき 10 になってしまう
const value = count || 10;
// ✅ null / undefined のときだけデフォルト値
const value = count ?? 10;
const city = user?.address?.city ?? "不明";- プロジェクト内で
nullとundefinedのどちらを「値がない」の表現に使うか統一すると混乱が減ります
7. 関数の設計
引数が多い場合はオブジェクトで受け取る
// ❌ 引数の順番を間違えやすい
function createUser(name: string, email: string, age: number, isAdmin: boolean) {}
// ✅ 名前付き引数のように使える
function createUser(params: {
name: string;
email: string;
age: number;
isAdmin?: boolean;
}) {}boolean フラグ引数を避ける
// ❌ 呼び出し側で意味が分からない
fetchUsers(true);
// ✅
fetchUsers({ includeInactive: true });引数は広く、戻り値は狭く
// 引数は readonly で受けると、可変・不変どちらの配列も渡せる
function sum(values: readonly number[]): number {
return values.reduce((a, b) => a + b, 0);
}オーバーロードよりユニオンや条件型を検討
オーバーロードは複雑になりがちです。ユニオン型で表現できるならそちらを優先します。
8. ジェネリクス
意味のある制約を付ける
// ❌ 制約がなく、T について何も分からない
function getProp<T>(obj: T, key: string) {}
// ✅ キーの存在を型で保証
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}不要なジェネリクスを作らない
型パラメータが 1 回しか登場しないなら、ジェネリクスは不要なことが多いです。
// ❌ T が引数に1回しか出てこない
function log<T>(value: T): void {
console.log(value);
}
// ✅
function log(value: unknown): void {
console.log(value);
}型パラメータ名
単純なものは T, K, V で十分ですが、複数あるときは TInput, TOutput のように意味のある名前を付けます。
9. ユーティリティ型の活用
既存の型から派生させることで、定義の重複と不整合を防ぎます。
| ユーティリティ型 | 説明 |
|---|---|
Partial<T> | すべてのプロパティをオプショナルに |
Required<T> | すべてのプロパティを必須に |
Readonly<T> | すべてのプロパティを読み取り専用に |
Pick<T, K> | 指定したプロパティだけを抽出 |
Omit<T, K> | 指定したプロパティを除外 |
Record<K, V> | キー K、値 V のオブジェクト型 |
Exclude<T, U> / Extract<T, U> | ユニオンから除外 / 抽出 |
NonNullable<T> | null と undefined を除外 |
ReturnType<F> / Parameters<F> | 関数の戻り値 / 引数の型 |
Awaited<T> | Promise を解決した型 |
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
type CreateUserInput = Omit<User, "id" | "createdAt">;
type UpdateUserInput = Partial<CreateUserInput>;
type UserPreview = Pick<User, "id" | "name">;10. イミュータビリティ
interface Config {
readonly apiUrl: string;
readonly retries: number;
}
function process(items: readonly string[]) {
items.push("x"); // ❌ コンパイルエラー
}
// 新しい配列を返すメソッドを優先
const sorted = items.toSorted(); // sort() は元配列を変更する
const reversed = items.toReversed();
const updated = items.with(0, "new");- 基本は
const、再代入が必要なときだけlet。varは使わない - 状態更新はスプレッド構文などで新しいオブジェクトを作る
11. enum の代替
enum はランタイムコードを生成し、数値 enum は型安全性に穴もあります。多くの場合、ユニオン型 または as const オブジェクトで代替できます。
// ❌ enum
enum Direction {
Up,
Down,
}
// ✅ ユニオン型(最もシンプル)
type Direction = "up" | "down";
// ✅ 値の一覧も必要なら as const オブジェクト
const Direction = {
Up: "up",
Down: "down",
} as const;
type Direction = (typeof Direction)[keyof typeof Direction]; // "up" | "down"12. エラーハンドリング
catch の例外は unknown として扱う
strict 下では catch (e) の e は unknown です(useUnknownInCatchVariables)。
try {
await save();
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);
} else {
logger.error("Unknown error", { error });
}
}独自エラークラスで原因を区別する
class NotFoundError extends Error {
override readonly name = "NotFoundError";
constructor(public readonly resource: string, options?: ErrorOptions) {
super(`${resource} not found`, options);
}
}
// cause でエラーの連鎖を保持
throw new NotFoundError("User", { cause: originalError });想定内の失敗は Result 型で表現する
例外は「想定外」の失敗に、想定内の失敗(バリデーションエラーなど)は戻り値で表現すると、呼び出し側が処理を強制されます。
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number, string> {
const n = Number(input);
if (!Number.isInteger(n) || n < 0) {
return { ok: false, error: "年齢は0以上の整数で入力してください" };
}
return { ok: true, value: n };
}13. 非同期処理
- コールバックより
async/awaitを使う - Promise を放置しない(ESLint の
no-floating-promisesで検出) - 独立した処理は並列化する
// ❌ 直列実行で遅い
const user = await fetchUser(id);
const posts = await fetchPosts(id);
// ✅ 並列実行
const [user, posts] = await Promise.all([fetchUser(id), fetchPosts(id)]);
// 一部の失敗を許容するなら allSettled
const results = await Promise.allSettled(tasks);forEachの中でawaitしない(待たれない)。for...ofかPromise.all+mapを使う- キャンセルが必要な処理には
AbortSignalを受け取れるようにする
14. 外部データの検証
TypeScript の型はコンパイル時にしか存在しません。 API レスポンス、フォーム入力、環境変数、JSON.parse の結果などは実行時に検証が必要です。
スキーマバリデーションライブラリ(Zod、Valibot、ArkType など)を使うと、スキーマから型を導出でき、定義の二重管理を防げます。
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().nonnegative().optional(),
});
type User = z.infer<typeof UserSchema>; // スキーマから型を導出
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json()); // 不正なら例外
}環境変数も起動時にまとめて検証しておくと、設定ミスを早期に発見できます。
15. モジュールとインポート
- ES Modules(
import/export)を使う。namespaceやrequireは避ける - 名前付きエクスポートを優先する。 デフォルトエクスポートは名前がぶれやすく、リファクタリングや自動インポートと相性が悪い
- 型のみのインポートは
import typeを使う(verbatimModuleSyntaxで強制可能)
import type { User } from "./types";
import { createUser, type CreateUserInput } from "./user";- 巨大なバレルファイル(
index.tsで全部を re-export)は、循環参照やビルド・テストの遅延の原因になるため注意 - パスエイリアス(
@/componentsなど)を使う場合は、ビルドツール側の設定と揃える
16. クラスとオブジェクト指向
- クラスが本当に必要か検討する。状態を持たないなら関数とモジュールで十分なことが多い
- アクセス修飾子を明示する。真に非公開にしたいなら
#privateフィールド(ランタイムでも非公開) - 継承より合成(コンポジション)を優先する
- 依存はコンストラクタで注入し、テストしやすくする
class UserService {
readonly #repo: UserRepository;
constructor(repo: UserRepository) {
this.#repo = repo;
}
async findById(id: string): Promise<User | undefined> {
return this.#repo.find(id);
}
}17. 命名規則
| 対象 | 規則 | 例 |
|---|---|---|
| 変数・関数 | camelCase | userName, fetchUser() |
| 型・interface・クラス | PascalCase | User, UserService |
| 定数(モジュールレベル) | UPPER_SNAKE_CASE または camelCase | MAX_RETRIES |
| boolean | is / has / can / should 接頭辞 | isActive, hasPermission |
| ファイル名 | プロジェクトで統一(kebab-case が一般的) | user-service.ts |
- interface に
I接頭辞(IUser)は付けないのが TypeScript では一般的 - 略語を避け、意図が伝わる名前にする
18. ツールと品質管理
Lint とフォーマット
- ESLint + typescript-eslint:型情報を使ったルール(
recommendedTypeChecked/strictTypeChecked)を有効にする - Prettier または Biome:フォーマットは自動化し、レビューで議論しない
特に有用な typescript-eslint ルール:
no-explicit-anyno-floating-promises/no-misused-promisesno-unnecessary-conditionswitch-exhaustiveness-checkconsistent-type-importsprefer-nullish-coalescing/prefer-optional-chain
CI での型チェック
esbuild、swc、Vite などのトランスパイラは型チェックをしません。CI で必ず型チェックを実行します。
// package.json
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
}
}テスト
- Vitest や Jest でユニットテストを書く
- 型そのもののテストには
expectTypeOf(Vitest)や@ts-expect-errorを使う
// 意図的にエラーになることをテスト
// @ts-expect-error - 数値は受け付けない
createUser({ name: 123 });
@ts-ignoreではなく@ts-expect-errorを使いましょう。エラーが解消されたときに通知されます。
19. 避けるべきアンチパターン
| アンチパターン | 問題点 | 代替 |
|---|---|---|
any の多用 | 型安全性が失われ、伝播する | unknown + 絞り込み、ジェネリクス |
as の乱用 | 実行時の保証がない | 型ガード、スキーマ検証 |
!(非 null アサーション) | null の可能性を握りつぶす | ?.、??、早期 return |
@ts-ignore | エラーを隠す | 根本修正、やむを得なければ @ts-expect-error + 理由コメント |
Function / Object / {} 型 | ほぼ何でも受け入れる | 具体的なシグネチャ、Record<string, unknown> |
ラッパー型 String / Number | プリミティブと異なる | string / number |
| 型定義の重複 | 不整合の原因 | ユーティリティ型やスキーマから導出 |
| オプショナルだらけの状態型 | 不正な状態を許す | 判別可能なユニオン |
数値 enum | 任意の数値を受け入れてしまう | ユニオン型、as const |
| 過度に複雑な型パズル | 可読性・コンパイル速度の低下 | シンプルな型、必要ならコメントで意図を説明 |
20. チェックリスト
プロジェクト設定
-
strict: trueが有効 -
noUncheckedIndexedAccessが有効 -
verbatimModuleSyntax/isolatedModulesが有効 - ESLint(typescript-eslint の型チェック付きルール)を導入
- フォーマッタを導入し自動実行
- CI で
tsc --noEmitを実行
コードレビュー時
-
any/as/!/@ts-ignoreが正当な理由なく使われていない - 外部データは実行時に検証されている
- 状態は判別可能なユニオンで表現され、網羅性チェックがある
- 公開関数の引数・戻り値に型が明示されている
- 型定義が重複せず、既存の型から導出されている
- Promise が放置されていない
-
||ではなく??を使うべき箇所がない - 名前から意図が分かる