type-system-patterns
リポジトリ: anomalyco/opencode 分析日: 2026-03-05
概要
TypeScript の型システムをどのように活用してランタイム安全性とコードの簡潔さを両立させているかを分析する。opencode は Zod v4 をスキーマの唯一の情報源(Single Source of Truth)として徹底活用し、namespace + 同名型エクスポートによるモジュール構造、ジェネリクスによるツールシステムの型安全なパラメータ伝播など、大規模 TypeScript コードベースで実践的に機能するパターンを豊富に持つ。型注釈を最小化しつつもバリデーション境界を厳密に守る設計方針が注目に値する。
背景にある原則
- Schema-first: スキーマが型と検証の両方を駆動すべき: 型定義と実行時バリデーションを別々に管理すると乖離する。Zod スキーマを定義し
z.infer<typeof Schema>で型を導出することで、型とバリデーションの一貫性を構造的に保証する(packages/opencode/src/session/message-v2.ts全体で徹底) - 明示的な型注釈の最小化: 型推論で十分な箇所に型注釈を書かない。ジェネリクスの制約と Zod スキーマから型が自動的に伝播するため、冗長な型宣言が不要になる(
packages/util/src/fn.ts:3のfn関数が代表例) - 名前空間によるドメイン凝集: TypeScript の
namespaceを使い、スキーマ・型・関数・エラーをひとつの名前空間にまとめることで、ドメインモデルの発見性と凝集度を高める(MessageV2,Provider,Config等) - バリデーション境界の明確化: 外部入力(ユーザー入力・API レスポンス・ツール引数)は必ず Zod で parse し、内部では検証済みの型として扱う。
fn()ユーティリティが「境界でバリデーション、内部は信頼」パターンを自動化する
実例と分析
Zod スキーマからの型導出と同名エクスポートパターン
opencode で最も広範に使われるパターンが、Zod スキーマと同名の型を export する手法である。namespace 内で const Schema = z.object(...) と type Schema = z.infer<typeof Schema> を並べ、TypeScript の「値と型は別の名前空間」という特性を利用して衝突なく共存させる。
// packages/opencode/src/permission/next.ts:30-39
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
});
export type Rule = z.infer<typeof Rule>;このパターンが message-v2.ts では 20 箇所以上、config.ts では 15 箇所以上で使われ、プロジェクト全体の型定義の基本形になっている。
discriminatedUnion による型安全なバリアント管理
多態的なデータ構造には z.discriminatedUnion を一貫して使用する。type や status フィールドをディスクリミネータとし、パターンマッチで安全に分岐する。
// packages/opencode/src/session/message-v2.ts:328-332
export const ToolState = z
.discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
.meta({
ref: "ToolState",
});// packages/opencode/src/session/message-v2.ts:376-394
export const Part = z
.discriminatedUnion("type", [
TextPart,
SubtaskPart,
ReasoningPart,
FilePart,
ToolPart,
StepStartPart,
StepFinishPart,
SnapshotPart,
PatchPart,
AgentPart,
RetryPart,
CompactionPart,
])
.meta({ ref: "Part" });
export type Part = z.infer<typeof Part>;Info メッセージ型も role フィールドで User | Assistant を区別し、error フィールドも name で7種のエラーを区別する。ネストした discriminatedUnion の組み合わせで複雑なドメインモデルを型安全に表現している。
ジェネリクスによるツール定義の型伝播
Tool.define はジェネリクスで Zod スキーマ型を受け取り、execute のコールバック引数まで型を自動伝播させる。
// packages/opencode/src/tool/tool.ts:27-42
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: (ctx?: InitContext) => Promise<{
description: string
parameters: Parameters
execute(
args: z.infer<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
}>
}>
}
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Info<Parameters, Result>["init"] | Awaited<ReturnType<Info<Parameters, Result>["init"]>>,
): Info<Parameters, Result> { ... }Tool.define の第2引数はオブジェクトリテラルも関数も受け付ける共用型 init | Awaited<ReturnType<init>> であり、用途に応じた柔軟な呼び出しを可能にしている。さらに条件型 InferParameters / InferMetadata でツール型から逆引きもできる(tool.ts:45-46)。
バリデーション付き関数ラッパー fn
// packages/util/src/fn.ts:3-11
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input);
return cb(parsed);
};
result.force = (input: z.infer<T>) => cb(input);
result.schema = schema;
return result;
}fn は「入力スキーマ + コールバック」から「バリデーション付き関数」を生成するユーティリティである。戻り値には .force(バリデーションスキップ)と .schema(スキーマ参照)が付与される。message-v2.ts:731, permission/next.ts:131 等、データアクセス関数の大半がこのラッパーを使用する。
NamedError: Zod で型安全なエラー階層
// packages/util/src/error.ts:7-46
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
const schema = z.object({ name: z.literal(name), data })
const result = class extends NamedError {
public override readonly name = name as Name
constructor(public readonly data: z.input<Data>, options?: ErrorOptions) {
super(name, options)
}
static isInstance(input: any): input is InstanceType<typeof result> {
return typeof input === "object" && "name" in input && input.name === name
}
// ...
}
return result
}リテラル型 Name をジェネリクスで受け取り、isInstance 型ガードを自動生成する。エラーの data フィールドも Zod で型付けされ、.toObject() でシリアライズ可能。message-v2.ts では 7 種類のエラーがこのファクトリで定義され、z.discriminatedUnion("name", [...]) でまとめて扱われる。
Drizzle ORM と $type<> による DB 型マッピング
// packages/opencode/src/session/session.sql.ts:50
data: text({ mode: "json" }).notNull().$type<InfoData>(),$type<T>() で Drizzle カラムに TypeScript 型を付与し、JSON カラムの入出力を型安全にする。InfoData は Omit<MessageV2.Info, "id" | "sessionID"> として定義され、DB 保存時に不要なフィールドを除外する構造的な型変換を行っている。
Context による型安全な DI(AsyncLocalStorage ラッパー)
// packages/opencode/src/util/context.ts:10-24
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) throw new NotFound(name);
return result;
},
provide<R>(value: T, fn: () => R) {
return storage.run(value, fn);
},
};
}ジェネリクス <T> で格納する値の型を固定し、use() の戻り値が常に T となる。Instance モジュール(project/instance.ts)がこれを使って directory, worktree, project をスコープ付きで提供する。
BusEvent の型安全なイベントシステム
// packages/opencode/src/bus/bus-event.ts:12
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
return { type, properties };
}// packages/opencode/src/bus/index.ts:41-44
export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) { ... }イベント定義時にリテラル型 Type と Zod スキーマ Properties を捕捉し、publish / subscribe でイベント名とペイロードの型を一致させる。文字列ベースのイベント名でありながらコンパイル時にペイロード型を検証できる。
パターンカタログ
Abstract Factory (生成)
- 解決する問題: エラークラスのボイラープレート削減と型安全なインスタンス検査
- 適用条件: 同一構造のバリアント型を大量に定義する場面
- コード例:
packages/util/src/error.ts:7NamedError.create() - 注意点: ファクトリの戻り値がクラスそのものであるため、
typeofとInstanceTypeの使い分けに注意
Typed Event Channel (振る舞い)
- 解決する問題: pub/sub イベントシステムでのペイロード型の保証
- 適用条件: イベント駆動アーキテクチャで型安全性を維持したい場面
- コード例:
packages/opencode/src/bus/bus-event.ts:12,packages/opencode/src/bus/index.ts:41 - 注意点:
z.outputとz.inferの使い分けに注意(transform がある場合に差が出る)
Good Patterns
Schema = Type + Validation の統一:
const X = z.object({...})とtype X = z.infer<typeof X>を対にすることで、型定義とバリデーションの乖離を原理的に防止する。opencode ではmessage-v2.tsに 20 箇所以上の適用例があり、コードベース全体の標準パターンとなっている。.meta({ ref: "..." })による OpenAPI スキーマ生成: Zod スキーマに.meta()で参照名を付与し、API スキーマの自動生成に利用する。型定義とドキュメントの二重管理を排除する。
// packages/opencode/src/session/message-v2.ts:86-91
export const SnapshotPart = PartBase.extend({
type: z.literal("snapshot"),
snapshot: z.string(),
}).meta({
ref: "SnapshotPart",
});satisfiesによる型検証付きオブジェクトリテラル:asではなくsatisfiesを使うことで、型の安全な検証と推論の保持を両立する。
// packages/opencode/src/agent/agent.ts:319
} satisfies Parameters<typeof generateObject>[0]Omitによる DB 保存用型の導出: 保存時に不要なフィールドをOmitで構造的に除外し、ドメイン型と永続化型の関係を型レベルで明示する。
// packages/opencode/src/session/session.sql.ts:8-9
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">;
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">;Anti-Patterns / 注意点
anyの漏出: プロジェクトスタイルガイドでany回避を推奨しているが、一部でMetadataインターフェースの[key: string]: anyやマイグレーションコード内のreadJson<any>が残存する。unknown+ 型ガードに置き換えるべき場面がある。
// Bad: packages/opencode/src/tool/tool.ts:9
interface Metadata {
[key: string]: any;
}
// Better:
interface Metadata {
[key: string]: unknown;
}asキャストによる型安全性の迂回: DB からの読み出し時にas MessageV2.Part,as MessageV2.Infoでキャストしている箇所がある。Zod parse を通していないため、スキーマ変更時にランタイムエラーになるリスクがある。
// Bad: packages/opencode/src/session/message-v2.ts:764
const part = { ...row.data, id: row.id, ... } as MessageV2.Part
// Better: Zod parse を挟む、または DB マイグレーションで整合性を保証
const part = MessageV2.Part.parse({ ...row.data, id: row.id, ... })導出ルール
[MUST]外部境界(API 入力・ツール引数・設定ファイル読み込み)では Zod スキーマで parse し、内部では推論された型を信頼する- 根拠:
fn()ラッパーとTool.defineが全ツール引数をschema.parse(args)で検証している(packages/opencode/src/tool/tool.ts:59,packages/util/src/fn.ts:5)
- 根拠:
[MUST]ドメインモデルの型は Zod スキーマからz.infer<typeof Schema>で導出し、手動の型定義と二重管理しない- 根拠:
message-v2.ts,config.ts,provider.ts等で 50 箇所以上が一貫してこのパターンを適用し、型とバリデーションの乖離を防止している
- 根拠:
[SHOULD]多態的なデータにはz.discriminatedUnionを使い、ディスクリミネータフィールドでバリアントを識別する- 根拠:
ToolState(status 分岐)、Part(type 分岐)、Info(role 分岐)等、15 箇所以上で一貫して使用され、パターンマッチの型安全性を保証している
- 根拠:
[SHOULD]ジェネリクスの制約に Zod スキーマ型を使い、スキーマ→引数型→戻り値型の自動伝播チェーンを構築する- 根拠:
Tool.Info<Parameters extends z.ZodType>がexecute(args: z.infer<Parameters>)まで型を伝播させ、各ツール実装で明示的な型注釈なしに型安全を実現している
- 根拠:
[SHOULD]namespace + 同名エクスポート(値と型)を使い、関連する定数・型・関数をドメイン単位で凝集させる- 根拠:
MessageV2,Provider,Config,PermissionNext等 40 以上の namespace が一貫してこのパターンを採用し、import の簡潔さとドメインの発見性を両立している
- 根拠:
[AVOID]DB から読み出した JSON をas Tでキャストする — Zod parse を通すか、マイグレーションでスキーマ整合性を保証する- 根拠:
message-v2.ts:764,772でas MessageV2.Part/as MessageV2.Infoが使われているが、スキーマ変更時の静的検出が不可能になる
- 根拠:
適用チェックリスト
- [ ] 外部入力を受け取るすべての関数で Zod スキーマによるバリデーションが行われているか
- [ ] 型定義が Zod スキーマから
z.inferで導出されており、手動のinterface/typeと二重管理になっていないか - [ ] 多態的なデータ構造に
discriminatedUnionを使い、ディスクリミネータフィールドを明示しているか - [ ] ジェネリクスを使う関数で、型パラメータが呼び出し側まで自動伝播するか(明示的な型引数の指定が不要か)
- [ ] DB の JSON カラムに
$type<T>()で型を付与し、ドメイン型との関係をOmit/Pickで明示しているか - [ ] イベントシステムでペイロード型がコンパイル時に検証されるか(
BusEvent.define+ ジェネリクスの活用) - [ ]
any型が境界部分以外に漏出していないか —unknown+ 型ガードで代替できないか検証する