Claude Codeにコードを書かせまくっている私の感覚をClaudeと壁打ちしながらまとめたら、いい感じのコードを初手で書かせるコツが生まれた。
つよつよエンジニアのコードレビューとエージェント
事前に明文化されたことではなく知識と経験に基づく判断とレビューでコード品質を担保してきた。 e.g. 「ここnullチェックいる」「このケース漏れてない?」
エージェントは、つよつよエンジニアが持っている明文化されてない知識と経験をコンテキストに持てず、レビュー内容を内面化できないため、同じレビューを何回もせなあかんという問題が発生する。
SubagentsやSkillsでルールを伝えることもできるんやけど、型で伝えてコンパイラが強制した方がより意図通りのコードが生まれやすい。
バグの匂いのするイヤな感じのコード
バグが発生する原因のひとつに「想定外の状態」がある。
- a. 存在しない値の参照(e.g. NullPointerException)
- b. 想定外の状態遷移
- c. 処理し忘れた分岐
これらを型で表現することでエージェントがコンパイラで検証できれば安全なコードを書く確率が高くなる。具体的には
- a. non-nullにする
- b. sealed/discriminated unionで想定している状態を明示する
- c. Result/Eitherとパターンマッチングで網羅性チェックする
を意識して型を定義する。最近のエージェントは賢いので、コンパイルを通すだけのコードやなくて意図を汲み取ったコードを書いてくれる。
Twitterのようなアプリケーションをつくるとして、バックエンドをTypeScript、アプリをKotlinで書いたらこんなイメージ。
ドメインモデルを設計する。
// タイムライン取得のドメインモデルとワークフロー
// Branded Types
type UserId = string & { readonly brand: unique symbol };
type TweetId = string & { readonly brand: unique symbol };
// タイムラインのアイテム種別をdiscriminated unionで表現してnullを防ぐ
type TimelineItem =
| { type: "tweet"; tweet: TweetContent }
| { type: "retweet"; retweetedBy: User; tweet: TweetContent }
| { type: "ad"; ad: AdContent };
// 次のカーソルがnullableなんはしゃーない
type Timeline = {
items: TimelineItem[];
nextCursor: string | null;
};
// 想定してるエラーを洗い出す
type GetTimelineError =
| { type: "USER_NOT_FOUND" }
| { type: "USER_SUSPENDED" }
| { type: "RATE_LIMITED"; retryAfter: number }
| { type: "INTERNAL_ERROR" };
// ワークフローの関数
// neverthrowのResultAsyncを使う
type GetTimelineFunc = (userId: UserId, cursor?: string) => ResultAsync<Timeline, GetTimelineError>;
エージェントの実装は以下のようになって型で意図を汲み取りやすくなる。
const getTimeline: GetTimelineFunc = (userId, cursor) => {
// 実装
};
// パターンマッチングで想定したエラーはすべてハンドリング(exhaustiveでないとエラーにする)
const handleGetTimeline = async (req: Request, res: Response) => {
const result = await getTimeline(req.userId, req.query.cursor);
result.match(
(timeline) => res.json(timeline),
(error) => {
switch (error.type) {
case "USER_NOT_FOUND":
return res.status(404).json({ error: "User not found" });
case "USER_SUSPENDED":
return res.status(403).json({ error: "Account suspended" });
case "RATE_LIMITED":
return res.status(429).json({ retryAfter: error.retryAfter });
case "INTERNAL_ERROR":
return res.status(500).json({ error: "Internal error" });
}
}
);
};
同様にアプリ側もこんな感じでUIの状態を定義する。
// ホーム画面の状態
sealed class HomeUiState {
data object Loading : HomeUiState()
data class Success(val timeline: List<TimelineItem>) : HomeUiState()
data class Error(val error: HomeError) : HomeUiState()
}
// タイムラインのアイテム種別
// nullableなプロパティがなくなる
sealed class TimelineItem {
data class Tweet(val tweet: TweetContent) : TimelineItem()
data class Retweet(val retweetedBy: User, val tweet: TweetContent) : TimelineItem()
data class Ad(val ad: AdContent) : TimelineItem()
}
sealed class HomeError {
data object NetworkError : HomeError()
data object RateLimited : HomeError()
data object SessionExpired : HomeError()
data object Unknown : HomeError()
}
エージェントはこんなコードを書いてくれる。
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is HomeUiState.Loading -> LoadingIndicator()
is HomeUiState.Success -> {
LazyColumn {
items(state.timeline) { item ->
when (item) {
is TimelineItem.Tweet -> TweetCard(item.tweet)
is TimelineItem.Retweet -> RetweetCard(item.retweetedBy, item.tweet)
is TimelineItem.Ad -> AdCard(item.ad)
}
}
}
}
is HomeUiState.Error -> {
// 設計時に想定されたエラーが漏れるとexhaustiveでなくなってコンパイルエラーが発生する
// Unknownなエラーが発生したときにどう対応すべきかを考えさせられる
when (state.error) {
is HomeError.NetworkError -> NetworkErrorMessage()
is HomeError.RateLimited -> RateLimitedMessage()
is HomeError.SessionExpired -> SessionExpiredMessage()
is HomeError.Unknown -> UnknownMessage()
}
}
}
}
どっちにしてもエラーハンドリングが漏れるなど設計意図からずれたらコンパイルで気付かはる。
つよつよエンジニアの役割
いままでコードレビューで指摘していた考慮漏れを実装前の型設計に組み込むことで、エージェントに意図を正確に伝えることができエージェントはコンパイルによってミスを防ぐことができるようになる。
こんな感じの意識でやってるので、初手でのいい感じのPull Requestをつくる可能性が高まってるんちゃうかな〜。
ほな良いお年を〜!🎍