meta-design-expressive-typescript について
meta-design-expressive-typescriptは、読みやすいTypeScriptを書く、またはリファクタリングする開発者向けのコーディングスキルです。オーケストレーターと役割を絞った純粋関数を分け、意図が伝わる命名と必要な抽出でコードの流れを明確にします。
主な機能
- 01オーケストレーターと純粋関数の分離
- 02意図を伝える名前付け
- 03ガード節による条件分岐の整理
- 04判別可能なユニオンと網羅的switch
- 05可読性に基づく抽出判断
活用シーン
- 01検証と変換が混在する関数のリファクタリング
- 02深くネストした条件分岐の整理
- 03述語や変換処理の明確な命名
- 04ユーティリティライブラリの有用性を判断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill meta-design-expressive-typescriptインストール後、Claude Code で/meta-design-expressive-typescriptを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/core.mddata-transforms.md詳細
表現力の高い TypeScript
クイックガイド: 読み手が各部分を頭の中でシミュレーションしなくても、意図が伝わるコードを書きましょう。2 層パターンを適用します。上位には擬似コードのように読めるオーケストレーターを置き、下位にはそれぞれ 1 つの処理を行う純粋関数を配置します。コードが文章のように読めるまで処理を切り出します。ユーティリティライブラリは、プレーンな JS より本当に読みやすくなる場合にのみ使用します。
<critical_requirements>
重要:このスキルを使用する前に
すべてのコードは、CLAUDE.md のプロジェクト規約に従う必要があります(kebab-case、名前付きエクスポート、インポート順序、
import type、名前付き定数)。
(すべての非自明な関数は、必ず 2 層のオーケストレーターとして構成する必要があります。先頭にガード節、中央に名前付き関数の呼び出し、末尾に組み立てを置きます。オーケストレーター内でのインラインデータ変換は禁止です。)
(理解するために頭の中でシミュレーションが必要な式は、必ず名前付き関数または名前付き定数に切り出す必要があります。)
(関数名は、どのように処理するかではなく、何をするかを表す名前にする必要があります。checkLineStartsWithPlusButNotTriplePlus(line) ではなく、isContentAddition(line) とします。)
(リファクタリングの前に、必ず既存のコードを読む必要があります。現在の構成を把握してから改善します。)
(明確に読める場合は、ユーティリティライブラリよりプレーンな JS のメソッド(.map()、.filter()、.reduce())を優先する必要があります。)
</critical_requirements>
自動検出: オーケストレーターパターン、2 層関数、関数の切り出し、名前付き述語、名前付き定数、可読性のためのリファクタリング、表現力の高いコード、関数の分解、純粋関数の切り出し、読みやすい TypeScript、ガード節、早期リターン、条件分岐のフラット化、判別可能なユニオン、網羅的な switch、as const satisfies、非同期オーケストレーター
使用する場合:
- バリデーション、変換、組み立てのロジックが混在する関数を書くとき
- 処理の流れを理解するために頭の中でシミュレーションが必要な関数をリファクタリングするとき
- 意図が伝わる述語、定数、変換処理に名前を付けるとき
- ユーティリティライブラリの関数を使うか、プレーンな JS を使うか判断するとき
- 大きな関数をオーケストレーターと純粋なヘルパー関数に分解するとき
- コードをレビューし、頭の中でのシミュレーションが必要なブロックを見つけたとき
- 深くネストした if/else ブロックをガード節でフラット化するとき
- 複数の独立した処理をオーケストレーションする非同期関数を書くとき
- 網羅的な処理のために、判別可能なユニオンで状態やイベントをモデル化するとき
使用しない場合:
- すでに明確な、シンプルな 1 行関数を書くとき
- 学術的な関数型プログラミング(モナド、ファンクター、Either/Option 型)
- 引数が暗黙的になるポイントフリースタイル
- 些細な式を名前付き関数に過剰に切り出すとき
- 関数呼び出しのオーバーヘッドが問題となる、パフォーマンスが重要なホットパス
主な対象パターン:
- 2 層パターン(オーケストレーターと純粋関数)
- 可読性テスト(「シミュレーションしなくても理解できるか?」)
- 名前付き述語、定数、変換処理
- ガード節:早期リターンによるネストした条件分岐のフラット化
- 型安全な制御フローのための判別可能なユニオンと網羅的な switch
- 独立した処理に
Promise.allを使う非同期オーケストレーター - 意図が伝わる設定のための
as const satisfies - 切り出しを判断するためのフレームワーク
- ユーティリティライブラリの使用:80/20 の法則
詳細なリソース
- examples/core.md - 2 層パターン、ガード節、名前付き述語、名前付き定数、判別可能なユニオン、非同期オーケストレーター
- examples/data-transforms.md - データ変換パターン、プレーンな JS で十分な場合、ユーティリティライブラリが役立つ場合
- reference.md - 判断表を含むクイックリファレンスのチートシート
## 哲学
表現力豊かな TypeScript は、読みやすさを重視した、実践的な 80/20 の関数型プログラミングです。コードのあらゆるブロックに対する基本的な判断基準は次のとおりです。
「コードの各部分を頭の中でシミュレーションしなくても、処理の流れを理解できますか?」
答えが「いいえ」なら、シミュレーションが必要な部分を名前付きの関数または定数に抽出してください。答えが「はい」なら、たとえ理論上「よりきれい」にできるとしても、そのままにしてください。
これは次のようなものではありません。
- モナド、ファンクター、Either/Option 型 — これらは別のスキルで扱います
- ポイントフリースタイル — 暗黙の引数は、ほとんどの読者にとって意図を分かりにくくします
- 宗教的な関数型の純粋性 — オーケストレーター内の副作用は問題ありません。重要なのは、その下にある純粋関数です
- 過剰な抽出 — 似たコードが 3 行あるなら、時期尚早な抽象化よりそのままにするほうが適切です
核となる 2 つの考え方:
- オーケストレーターは擬似コードのように読めます。 ガード節、名前付き関数の呼び出し、組み立てで構成します。シミュレーションが必要なインラインロジックは含めません。
- 純粋関数は 1 つのことだけを行います。 それぞれの名前から目的が伝わります。また、それぞれを個別にテストできます。
このスキルを適用する場面:
- 複数の関心事が混在する、約 15 行を超える関数
- 意図を理解するために、読者がロジックを追う必要のある式
- 説明的な名前が付いていない、繰り返し使われるロジックのパターン
適用しない場面:
- それだけですでに分かりやすい、単一の
.map()や.filter() - すでに 1 つの抽象化レベルで書かれている関数
- 抽出するとノイズが増えるだけの、ごく単純なコード
基本パターン
パターン 1:2 層パターン
複雑な関数はすべて、同じ構造に従います。上位層には擬似コードのように読めるオーケストレーターを配置し、下位層にはそれぞれ 1 つのことだけを行う純粋関数を配置します。
オーケストレーター(上位層)
// Orchestrator: reads like a step-by-step plan
function processUserImport(rawData: RawImportData): ImportResult {
// 1. Guard clauses
if (!rawData.users.length) {
return { imported: 0, skipped: 0, errors: [] };
}
// 2. Named function calls for each step
const validated = rawData.users.filter(isValidUser);
const normalized = validated.map(normalizeUserRecord);
const deduped = removeDuplicatesByEmail(normalized);
const { existing, newUsers } = separateExistingUsers(deduped);
// 3. Assembly
return {
imported: newUsers.length,
skipped: existing.length,
errors: rawData.users.length - validated.length,
};
}
良い理由: 各行は関数名によって意図を伝えます。読者は各ステップの仕組みを読むことなく、そこで何が起きるかを把握できます。ガード節は冒頭にあり、インラインラムダで処理の流れが見えにくくなることもありません。
純粋関数(下位層)
// Pure function: one job, named for purpose
function isValidUser(user: RawUser): boolean {
return user.email.includes("@") && user.name.trim().length > 0;
}
function normalizeUserRecord(user: RawUser): NormalizedUser {
return {
email: user.email.toLowerCase().trim(),
name: user.name.trim(),
role: user.role ?? DEFAULT_ROLE,
};
}
function removeDuplicatesByEmail(users: NormalizedUser[]): NormalizedUser[] {
const seen = new Set<string>();
return users.filter((user) => {
if (seen.has(user.email)) return false;
seen.add(user.email);
return true;
});
}
良い理由: 各関数は 1 つのことだけを行い、説明的な名前が付いており、モックや状態のセットアップなしで単独でテストできます。
変更前/変更後の完全な例: オーケストレーターを全面的に書き換える例は、examples/core.md を参照してください。
パターン 2:読みやすさのテスト
抽出やリファクタリングを行う前に、コードブロックごとに次のテストを適用してください。
「各部分を頭の中でシミュレーションしなくても、コードの処理の流れを理解できますか?」
// Fails the readability test -- requires simulation
const result = items
.filter(
(item) =>
item.status === "active" &&
item.createdAt > cutoffDate &&
!excludedIds.has(item.id),
)
.map((item) => ({
...item,
displayName: item.firstName + " " + item.lastName,
age: Math.floor((Date.now() - item.birthDate.getTime()) / MS_PER_YEAR),
}));
良くない理由: これが何を生成するのか理解するには、読者がフィルターの述語と map の変換処理を頭の中で実行する必要があります。意図が実装の中に埋もれています。
// Passes the readability test -- intent is clear from names
const activeItems = items.filter(isActiveAfterCutoff);
const displayRecords = activeItems.map(toDisplayRecord);
良い理由: 各ステップが意図を伝えています。読者は、カットオフ時点より後のアクティブな項目をフィルターで選び、map で表示用レコードを作成することを、どちらの関数本体も読むことなく把握できます。
テストの結果が「そのままにする」の場合
// Already passes -- don't over-extract
const names = users.map((user) => user.name);
const activeUsers = users.filter((user) => user.isActive);
const total = prices.reduce((sum, price) => sum + price, 0);
良い理由: これらはすでに明確な単一式の操作です。getName、isActive、sumPrices を抽出しても、分かりやすさは向上せず、間接性が増すだけです。
パターン 3:名前付きの抽象化
式の理解にシミュレーションが必要な場合は、意図が伝わる名前を付けてください。
名前付き述語
// Before: requires simulation to understand filter criteria
const lines = diff.filter(
(line) => line.startsWith("+") && !line.startsWith("+++"),
);
// After: name communicates intent
const lines = diff.filter(isContentAddition);
function isContentAddition(line: string): boolean {
return line.startsWith("+") && !line.startsWith("+++");
}
良い理由: isContentAddition は、何を確認しているのかを読者に伝えます。実装(startsWith のロジック)を確認することもできますが、処理の流れを理解するために必須ではありません。
名前付き定数
// Before: magic setup with no context
const skill = createMockSkill("react", {
conflictsWith: ["vue", "angular"],
requires: ["typescript"],
});
// After: name communicates purpose
const REACT_WITH_FRAMEWORK_CONFLICTS = createMockSkill("react", {
conflictsWith: ["vue", "angular"],
requires: ["typescript"],
});
良い理由: この定数がテストに現れたとき、読者は定義までスクロールしなくてもその目的を把握できます。名前に、関連する特性が含まれています。
名前付き変換処理
// Before: inline formatting logic obscures orchestrator flow
const message = results
.map((r) =>
r.success ? ` Installed ${r.name}` : ` Failed ${r.name}: ${r.error}`,
)
.join("\n");
// After: name communicates intent
const message = results.map(formatInstallResult).join("\n");
function formatInstallResult(result: InstallResult): string {
if (result.success) return ` Installed ${result.name}`;
return ` Failed ${result.name}: ${result.error}`;
}
良い理由: オーケストレーターは「各結果をフォーマットし、改行で結合する」と読めます。フォーマットの詳細を確認することもできますが、処理の流れを妨げません。
その他の例: 名前付きの抽象化パターンの変更前/変更後の例は、examples/core.md を参照してください。
### パターン 4:抽出するかどうかの判断フレームワーク
すべての式を抽出する必要はありません。次の判断ツリーを参考にしてください。
Does this block require mental simulation to understand?
|-- NO -> Leave it inline. Don't over-extract.
+-- YES -> Does the same logic appear in 2+ places?
|-- YES -> Extract to shared function (DRY + readability).
+-- NO -> Would a named function communicate intent the expression doesn't?
|-- YES -> Extract. The name adds value.
+-- NO -> Leave it. Extraction would just move code without adding clarity.
抽出する:ブロックの動作をシミュレーションする必要がある
// Before: simulation required to understand what this produces
const categories = Object.entries(skills).reduce<Record<string, Skill[]>>(
(acc, [id, skill]) => {
const cat = skill.category;
if (!acc[cat]) acc[cat] = [];
acc[cat].push(skill);
return acc;
},
{},
);
// After: intent is clear from the name
const categories = groupSkillsByCategory(skills);
抽出しない:すでに明確
// Already clear -- extracting would just move one line
const ids = skills.map((skill) => skill.id);
const hasConflicts = conflicts.length > 0;
const displayName = `${firstName} ${lastName}`;
良い理由: これらは一目で理解できます。getIds、checkHasConflicts、buildDisplayName という名前の関数を作っても、読みやすさは向上せず、間接性が増すだけです。
抽出しない:名前が実装と同じくらい複雑になる
// Don't extract this:
const isSelected = selectedIds.has(item.id);
// Because the extraction would be:
function isItemSelected(item: Item, selectedIds: Set<string>): boolean {
return selectedIds.has(item.id);
}
// The name doesn't communicate more than the expression itself
パターン 5:ガード節(ネストした条件分岐を平坦化する)
深くネストした if/else ブロックでは、読者は条件を頭の中で積み重ねて把握する必要があります。ガード節は条件を反転して早期に return することで、正常系をネストせずに保ちます。
// Flat: each guard exits early, happy path has zero nesting
function getDiscount(user: User, cart: Cart): number {
if (!user.isActive) return 0;
if (cart.items.length === 0) return 0;
if (!user.membership) return STANDARD_DISCOUNT;
return calculateMemberDiscount(user.membership, cart.total);
}
良い理由: 各ガード節は個別の懸念に対応して終了します。読者はどの else 分岐にいるかを追い続ける必要がありません。最後の行が正常系であることが、一目でわかります。
変換の全体像(変更前/変更後): 深くネストしたコードをガード節で平坦化した例は、examples/core.md を参照してください。
パターン 6:判別可能なユニオンと網羅的な switch
値が複数の状態のいずれかを取り得る場合、判別可能なユニオンと網羅的な switch を組み合わせると、すべてのケースが処理されていることを型システムで強制できます。新しいバリアントを追加すると、処理が不足している箇所でコンパイルエラーが発生します。
type TaskStatus = "pending" | "running" | "completed" | "failed";
function getStatusMessage(status: TaskStatus): string {
switch (status) {
case "pending":
return "Waiting to start";
case "running":
return "In progress...";
case "completed":
return "Done";
case "failed":
return "Something went wrong";
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}
良い理由: never のデフォルト分岐により、コンパイル時に網羅性が保証されます。ユニオンに 5 つ目のステータスを追加すると、新しいケースを処理するまでこの switch でエラーが発生します。
判別可能なオブジェクトユニオンを使ったパターン全体: 判別可能なユニオンパターンの全体像は、examples/core.md を参照してください。
パターン 7:非同期オーケストレーター
2 層パターンは非同期コードにも同様に適用できます。非同期オーケストレーターは、依存するステップでは名前付き関数を順番に await し、独立したステップでは Promise.all を使用します。
async function onboardNewUser(input: OnboardInput): Promise<OnboardResult> {
const user = await createUserRecord(input);
// Independent operations -- run in parallel
const [profile, settings] = await Promise.all([
initializeProfile(user.id, input.preferences),
applyDefaultSettings(user.id),
]);
await sendWelcomeEmail(user.email, profile.displayName);
return { user, profile, settings };
}
良い理由: オーケストレーターを読むと、手順を追った計画として理解できます。独立した処理は Promise.all にまとめられており、互いに依存していないことが読者に伝わります。呼び出される各関数は、目的に応じた名前が付けられた純粋な非同期処理です。
変更前/変更後の全体例: 複数の懸念が混在したコードから分解した非同期関数は、examples/core.md を参照してください。
パターン 8:設定に as const satisfies を使う
静的な設定オブジェクトを定義する場合、as const satisfies Shape を使うと、リテラル型(自動補完や typeof/keyof の使用に役立ちます)を保持しつつ、コンパイル時に形状を検証できます。
interface RouteConfig {
path: string;
auth: boolean;
}
const ROUTES = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
settings: { path: "/settings", auth: true },
} as const satisfies Record<string, RouteConfig>;
// Type of ROUTES.home.path is "/", not string
// typeof ROUTES gives you the full literal structure for keyof usage
良い理由: satisfies 演算子により、タイプミスやフィールドの不足をコンパイル時に検出できます。as const は正確なリテラル型を保持するため、後続のコードでより精密な型推論を利用できます。satisfies がなければ、{ pth: "/" } のようなタイプミスが検出されずに通ってしまいます。
<decision_framework>
判断フレームワーク
2 層パターンを適用する場面
Is this function > ~15 lines?
|-- NO -> Is it mixing multiple concerns (validate + transform + assemble)?
| |-- YES -> Apply two-tier pattern
| +-- NO -> Leave it. Short single-concern functions are fine as-is.
+-- YES -> Does it have clear logical steps?
|-- YES -> Apply two-tier pattern: orchestrator calls named functions
+-- NO -> Consider breaking into separate functions by responsibility first
ユーティリティライブラリとプレーンな JS の使い分け
Is this a single .map(), .filter(), or .reduce()?
|-- YES -> Use plain JS. A utility library adds dependency without clarity.
+-- NO -> Is the operation a known concept (group-by, count-by, set-difference)?
|-- YES -> Does the utility library name match the concept?
| |-- YES -> Use the library. groupBy(skills, s => s.category) is
| | clearer than a manual reduce.
| +-- NO -> Use plain JS with a named function.
+-- NO -> Are there 3+ chained transformations?
|-- YES -> Consider pipe() if intermediate variables would obscure flow.
+-- NO -> Use plain JS with named intermediate variables.
ユーティリティライブラリにおける 80/20 の法則
関数名そのものがドキュメントになる場合は、ユーティリティ関数を使います。
| ユーティリティライブラリを使う | プレーンな JS を使う |
|---|---|
groupBy(items, fn) -- キーごとにグループ化 | items.map(fn) -- 単一の変換 |
countBy(items, fn) -- カテゴリごとにカウント | items.filter(fn) -- 単一のフィルター |
difference(a, b) -- 集合の差 | items.find(fn) -- 単一の検索 |
partition(items, fn) -- 2 つに分割 | items.some(fn)/items.every(fn) |
indexBy(items, fn) -- 配列をルックアップマップに変換 | items.reduce(fn, init) -- 単純な合計 |
pipe(data, ...fns) -- 3 つ以上の処理を連結 | 1 つまたは 2 つの .map().filter() 呼び出し |
データ変換の全体例: examples/data-transforms.md を参照してください。
</decision_framework>
<red_flags>
注意すべき問題
優先度の高い問題:
- オーケストレーター関数に、単一の式を超える長さのインラインラムダが含まれている場合は、名前付き関数に抽出します
- 関数内で、ガード節、データ変換、副作用、戻り値の組み立てが明確に分離されていません
- 変数名が意図(
activeDisplayRecords)ではなく実装(filteredMappedItems)を表しています - 意図がより明確に伝わる
groupByやindexByではなく、.reduce()を使って検索オブジェクトを構築しています - ガード節による早期リターンではなく、深くネストした if/else ブロック(3 階層以上)を使っています
- 共用体型に対する switch に
default: neverによる網羅性チェックがありません。新しいバリアントを追加しても、黙ってフォールスルーします
優先度が中程度の問題:
- すでに明確に読める単一の
.map()や.filter()にユーティリティライブラリを使っています - ごく単純な式を名前付き関数に抽出しています(過剰な抽出)
- 変数を使うほうが明確な 1 つまたは 2 つの処理に
pipe()を使っています - 名前付き定数が、置き換え前の式と同じくらい複雑です
よくある間違い:
- ポイントフリースタイル(
items.filter(isActive))では述語が必要とするコンテキストが見えにくくなるのに、それを使っています。述語がクロージャ変数を必要とする場合は、明示的なラムダを使います - ちょうど 1 回だけ使われ、その名前が含まれる 1 行の単なる言い換えになっている関数を抽出しています
- 「utils」ファイルを何でも詰め込む場所にしています。抽出した関数は、呼び出し元の近くでドメインごとにまとめます
落とし穴とエッジケース:
- 適切な名前のインラインラムダは、すでに名前付きの抽象化です。
.filter((user) => user.isActive)はisActiveによって自己説明的なので、すでに明確です。これを別の関数に抽出しないでください - ユーティリティライブラリの
pipe()は、読み手がそのライブラリを知らない場合に可読性を損なうことがあります。チームの習熟度を考慮してください - ガード節は早期にリターンする必要があります。関数本体全体を
ifブロックで囲んではいけません - 純粋関数を抽出した場合は、ファイルの末尾または共有モジュールに配置します。処理をまとめるオーケストレーターの間に散在させないでください
as const satisfies Shapeは順序が重要です。satisfies Shape as constは機能しません。まずリテラルを固定し、その後で形状を検証します- 非同期オーケストレーターで、互いに独立した処理を順番に
awaitするのは、隠れたパフォーマンス上のバグです。各処理の結果が互いに依存しない場合はPromise.allを使います neverによる網羅性チェックには、変数が警告されないようnoUnusedLocalsが必要です。リント警告を避けるには、単に代入するのではなくreturn _exhaustiveを使います
</red_flags>
<critical_reminders>
重要な注意事項
すべてのコードは、CLAUDE.md に記載されたプロジェクトの規約(ケバブケース、名前付きエクスポート、インポート順序、
import type、名前付き定数)に従う必要があります
(些細ではない関数はすべて、2 層構造のオーケストレーターとして構成する必要があります。上部にガード節、中間に名前付き関数の呼び出し、末尾に組み立てを置きます。オーケストレーター内でのインラインデータ変換は禁止です)
(理解するために頭の中でシミュレーションが必要な式は、すべて名前付き関数または名前付き定数に抽出する必要があります)
(関数名は実装方法ではなく、処理内容を表す必要があります。checkLineStartsWithPlusButNotTriplePlus(line) ではなく isContentAddition(line) とします)
(リファクタリングの前に、必ず既存のコードを読みます。現在の構造を理解してから改善します)
(すでに明確に読める場合は、ユーティリティライブラリよりも通常の JS メソッド(.map()、.filter()、.reduce())を優先する必要があります)
これらのルールに従わないと、理解するために頭の中でシミュレーションが必要なコードになります。これは、表現力豊かな TypeScript が目指すものとは正反対です。
</critical_reminders>