push-null-to-perimeter について
関連する nullable 値を、すべて存在するかすべて欠ける型として設計する TypeScript スキルです。型を設計・リファクタリングする開発者向けで、散在する null チェックを減らします。
主な機能
- 01関連値を全有または全無の型に集約
- 02判別可能なユニオンで部分状態を表現
- 03null 処理をシステム境界に集約
- 04非同期初期化にファクトリーを使用
活用シーン
- 01関連するオプショナルフィールドをリファクタリング
- 02散在する null チェックの削減
- 03ロード中やエラー状態のモデル化
- 04部分初期化されたクラスの回避
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill push-null-to-perimeterインストール後、Claude Code で/push-null-to-perimeterを入力して読み込まれたか確認します。
詳細
型の null 値を周辺に押し出す
概要
値が完全に null であるか、完全に null でないかのどちらかになるように型を設計します。混在させないでください。
null の状態が混在すると、暗黙的な関係が生まれて追跡が難しくなり、あちこちに null チェックが散らばってバグにつながります。
このスキルを使う場面
- null を許容するフィールドが複数ある型を設計するとき
- コード中に null チェックが散らばっているとき
- 関連する値が同時に null になるとき
- 「undefined is not an object」エラーをデバッグするとき
- 多数のオプションプロパティを持つ型をリファクタリングするとき
絶対のルール
NEVER design types where null values have implicit relationships.
例外はありません:
- 「そのほうが簡単だから」は理由になりません
- 「実行時にチェックするから」は理由になりません
- 「フィールドは独立しているから」は理由になりません
検出:「null の混在」という兆候
2 つの値が同時に null になる、または同時に null でなくなるなら、その関係を型で表現します。
// ❌ VIOLATION: Implicit relationship between min and max
function extent(nums: number[]) {
let min: number | undefined;
let max: number | undefined;
for (const num of nums) {
if (min === undefined) {
min = num;
max = num;
} else {
min = Math.min(min, num);
max = Math.max(max, num); // Error! max might be undefined
}
}
return [min, max]; // [number | undefined, number | undefined]
}
// Caller has to deal with all four combinations:
const [min, max] = extent([1, 2, 3]);
// min defined + max defined
// min undefined + max undefined
// min defined + max undefined <- Impossible but allowed by type!
// min undefined + max defined <- Impossible but allowed by type!
解決策:すべてかゼロかの型
// ✅ CORRECT: Result is either fully present or fully absent
function extent(nums: number[]): [number, number] | null {
let result: [number, number] | null = null;
for (const num of nums) {
if (!result) {
result = [num, num];
} else {
result = [Math.min(num, result[0]), Math.max(num, result[1])];
}
}
return result;
}
// Caller only has two cases:
const result = extent([1, 2, 3]);
if (result) {
const [min, max] = result; // Both guaranteed to exist
}
例:投稿を持つユーザー
// ❌ BAD: Mixed nullability
class UserPosts {
user: UserInfo | null;
posts: Post[] | null;
constructor() {
this.user = null;
this.posts = null;
}
async init(userId: string) {
this.user = await fetchUser(userId);
this.posts = await fetchPosts(userId);
}
}
// At any moment, four states are possible:
// user null + posts null (before init)
// user null + posts non-null (during init - race condition!)
// user non-null + posts null (during init - race condition!)
// user non-null + posts non-null (after init)
// ✅ GOOD: All-or-nothing
class UserPosts {
user: UserInfo;
posts: Post[];
private constructor(user: UserInfo, posts: Post[]) {
this.user = user;
this.posts = posts;
}
static async create(userId: string): Promise<UserPosts> {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
]);
return new UserPosts(user, posts);
}
}
// Only two states: no instance, or fully loaded instance
const userPosts = await UserPosts.create(userId);
console.log(userPosts.user.name); // Always safe!
例:API レスポンス
// ❌ BAD: Data and error both optional
interface ApiResponse {
data?: ResponseData;
error?: Error;
loading: boolean;
}
// Confusing states are possible:
const bad: ApiResponse = {
data: someData,
error: someError, // Both data AND error?
loading: true, // Still loading but has data?
};
// ✅ GOOD: Each state is complete
type ApiResponse =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: ResponseData }
| { status: 'error'; error: Error };
// No confusion possible:
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'idle':
return null;
case 'loading':
return <Spinner />;
case 'success':
return <Data data={response.data} />; // data guaranteed
case 'error':
return <Error error={response.error} />; // error guaranteed
}
}
境界パターン
システム全体ではなく、システムの境界で null 許容を処理します:
// ❌ BAD: Null checks everywhere
function processUser(userId: string | null) {
if (!userId) return null;
const user = getUser(userId);
if (!user) return null;
const posts = getPosts(user);
if (!posts) return null;
return formatUserWithPosts(user, posts);
}
// ✅ GOOD: Check at boundary, then work with clean types
function processUser(userId: string | null): UserWithPosts | null {
// Handle null at the boundary
if (!userId) return null;
const user = getUser(userId);
if (!user) return null;
// After validation, work with non-null types
return formatUserWithPosts(user); // Takes User, not User | null
}
function formatUserWithPosts(user: User): UserWithPosts {
// No null checks needed inside - user is guaranteed non-null
const posts = user.posts; // Always exists
return { ...user, posts: posts.map(formatPost) };
}
クラス設計:完全に初期化するか、まったく初期化しないか
// ❌ BAD: Partially initialized state
class Connection {
socket: Socket | null = null;
protocol: Protocol | null = null;
async connect() {
this.socket = await createSocket();
this.protocol = await negotiateProtocol(this.socket);
}
send(data: string) {
if (!this.socket || !this.protocol) {
throw new Error('Not connected');
}
this.protocol.send(this.socket, data);
}
}
// ✅ GOOD: Factory ensures complete initialization
class Connection {
private constructor(
private socket: Socket,
private protocol: Protocol,
) {}
static async create(): Promise<Connection> {
const socket = await createSocket();
const protocol = await negotiateProtocol(socket);
return new Connection(socket, protocol);
}
send(data: string) {
// No null checks - always initialized
this.protocol.send(this.socket, data);
}
}
圧力に対抗するためのプロトコル
1. 「部分的な状態が必要です」
圧力:「すべてのデータが読み込まれる前に、オブジェクトを存在させる必要があります」
**回答:**部分的な状態用に別の型を作成するか、ファクトリーを使用します。
**アクション:**判別可能なユニオンまたは非同期ファクトリーを使用します。
2. 「こちらのほうが複雑です」
圧力:「オプションフィールドを持つ 1 つの型のほうが簡単です」
**回答:**あちこちに散らばった null チェックのほうが、整理された型より複雑です。
**アクション:**型の設計に最初から取り組みます。
危険信号 — 立ち止まって再検討してください
- 同時に null になるオプションフィールドが複数ある
- クラス内のあちこちに null チェックが散らばっている
- 非同期初期化における競合状態
- 「X は Y が設定されているときだけ設定されます」といったコメント
- 型が許容してしまう「あり得ない」状態
よくある正当化(すべて誤り)
| 言い訳 | 実際のところ |
|---|---|
| 「そのほうが簡単」 | null チェックが散らばっている状態は簡単ではありません。 |
| 「実行時にチェックする」 | 型はコンパイル時にエラーを検出します。 |
| 「フィールドは独立している」 | 同時に null になるなら、互いに関連しています。 |
クイックリファレンス
| パターン | 解決策 |
|---|---|
| 2 つの値が同時に null になる | タプルまたは null を返す |
| 読み込み状態を持つオブジェクト | 判別可能なユニオンを使用する |
| 非同期初期化を行うクラス | 静的ファクトリーメソッドを使用する |
| null 許容プロパティが混在する | ネストされたオブジェクトにまとめる |
要点
null は、すべてかゼロかの二択にします。
値が完全に存在するか、完全に存在しないかのどちらかになるように型を設計します。null の処理はコードの境界に押し出します。その結果、型がすっきりし、null チェックとバグが減ります。
参考文献
Dan Vanderkam 著『Effective TypeScript』項目 33「型の null 値を周辺に押し出す」に基づいています。