Skip to content

Metanaka TypeScript Style Rule

このリポジトリで TypeScript を書く・直す・レビューするときの style rule。 新規コードと変更コードは、const、arrow function、純粋関数を基本にする。

適用範囲

この rule は .ts.test.ts、Vue component の <script setup lang="ts"> に適用する。 既存 code を触る場合は、変更箇所とその周辺をこの rule に寄せる。 作業目的と無関係な広範囲の style 変更は避けるが、変更箇所の近くにある letfunction、過剰 export が同じ処理の理解を妨げている場合は一緒に直してよい。

例外を使う場合は、既存 API 互換、framework / library の要求、性能上の理由など、code から理由が分かる状態にする。 理由が説明できない例外は作らない。

基本方針

  • let は原則禁止する。
  • 関数はすべて arrow function で書く。
  • 純粋関数にできる処理は、純粋関数として切り出す。
  • 外部から使わせる意図がない helper は export せず、必要最小の scope にカプセル化する。
  • 副作用は Vue composable、adapter、event handler、外部 API 境界などに閉じ込める。
  • mutable な中間状態より、式、早期 return、配列メソッド、小さな関数分割を優先する。

const first

変数宣言は const を使う。再代入が必要に見える場合でも、まず構造を変えて const にできないか検討する。

優先する書き方:

ts
const resolveStatusLabel = (status: RoomStatus) => {
  if (status === "waiting") {
    return "waiting"
  }

  if (status === "playing") {
    return "playing"
  }

  return "finished"
}

const activePlayers = players.filter((player) => player.connected)

避ける書き方:

ts
let label = "finished"

if (status === "waiting") {
  label = "waiting"
}

if (status === "playing") {
  label = "playing"
}

let を許す例外は限定する。

  • 外部 library や platform API が mutable な変数を要求する場合。
  • loop 内の index や accumulator が、配列メソッドより明らかに読みやすい場合。
  • hot path で allocation を避ける必要があり、理由が code から分かる場合。
  • try block の外で値を使う必要があり、関数分割や Result 型では不自然になる場合。

例外として let を使う場合も、scope を最小にし、再代入の回数を増やさない。 let を使った方が短いだけ、または後から値を詰める方が慣れているだけの場合は例外にしない。

配列や object の中身を後から詰める書き方も、まず mapfilterreduce、条件分岐ごとの return、factory function で置き換えられないか検討する。

Arrow function

関数は arrow function で定義する。

ts
export const createDefaultRoomForm = (): RoomForm => ({
  theme: "",
  playerCount: 4,
})

const submitRoom = async (form: RoomForm) => {
  return postRoom(form)
}

避ける書き方:

ts
export function createDefaultRoomForm(): RoomForm {
  return {
    theme: "",
    playerCount: 4,
  }
}

新規コードでは function 宣言、function 式、object method shorthand、class method を避ける。 既存 API 互換、framework が要求する signature、外部 library callback の都合がある場合だけ例外にする。

object の値として関数を持たせる場合も、method shorthand ではなく arrow function property にする。

優先する書き方:

ts
const roomActions = {
  submit: async () => {
    return submitRoom()
  },
}

避ける書き方:

ts
const roomActions = {
  async submit() {
    return submitRoom()
  },
}

型で関数を表す場合も、method signature より property function type を優先する。

ts
type RoomActions = {
  submit: () => Promise<RoomResult>
}

Line Breaks

formatter / linter が折らない長さの式は、無理に複数行へ分けない。 特に短い object literal を 1 つ渡すだけの関数呼び出しは、1 行の方が処理の流れを追いやすい。 composable / factory で短い公開 API object を返す場合も、formatter が折らないなら 1 行で返す。

優先する書き方:

ts
await navigateTo({ path: "/room/join", query: { roomId } })

return { now: readonly(now), start, stop }

避ける書き方:

ts
await navigateTo({
  path: "/room/join",
  query: { roomId },
})

return {
  now: readonly(now),
  start,
  stop,
}

ただし、引数が複数ある、object が深い、条件式や callback を含む、formatter が自動で折る場合は複数行にする。 可読性のために折る場合は、argument、object property、callback のような意味単位で折る。 単に「縦に揃える」ためだけの改行は増やさない。

純粋関数

純粋関数とは、次の条件を満たす関数。

  • 同じ入力に対して、常に同じ出力を返す。
  • 関数の外側の mutable state を読まない。
  • 関数の外側の state、引数、DOM、storage、network、database を変更しない。
  • 現在時刻、乱数、環境変数、global singleton など、呼び出すたびに変わり得る値を直接参照しない。
  • logging、HTTP request、Firestore、Firebase Auth、router 遷移、timer 登録などの I/O を行わない。

純粋関数にできる処理は、Vue state や外部 API 境界から切り離す。

ts
export const canStartGame = (players: readonly Player[]) => {
  return players.length >= 3 && players.every((player) => player.connected)
}

export const createRoomSummary = (room: Room): RoomSummary => ({
  playerCount: room.players.length,
  canStart: canStartGame(room.players),
})

副作用が必要な処理では、impure な外側と pure な core を分ける。

ts
export const createRoomSetup = (dependencies: RoomSetupDependencies) => {
  const submit = async (form: RoomForm) => {
    const validation = validateRoomForm(form)

    if (!validation.ok) {
      return validation
    }

    return dependencies.postRoom(validation.data)
  }

  return {
    submit,
  }
}

この例では validateRoomForm を純粋関数にし、HTTP request は dependencies.postRoom に閉じ込める。

副作用を含む関数でも、入力整形、validation、状態遷移、表示用 summary 作成などは pure helper に分ける。 ただし、処理が短く、その場で読める guard や単純な object 組み立てまで無理に helper 化しない。 pure helper に切り出す判断は「再利用したいから」だけでなく「副作用と domain rule を分けると test とレビューが明確になるか」で決める。

現在時刻や乱数が必要な domain logic は、関数内で直接 Date.now()Math.random() を呼ばず、値または dependency として受け取る。

ts
export const createGameSeed = (randomValue: number) => {
  return Math.floor(randomValue * 100000)
}

表示用 summary など入力から出力を作る utility では、現在時刻も入力として受け取る。

ts
export const resolveRoomSummary = (
  players: readonly Player[],
  now: number,
) => ({
  activeCount: players.filter((player) => player.connected).length,
  generatedAt: now,
})

Export and Encapsulation

関数を小さく分けること、top-level に置くこと、外部へ公開することは別に考える。 module 内の見通しや test しやすさのために切り出した helper でも、他 module から直接使わせる contract でなければ export しない。 その composable の処理順序や局所 state の文脈でしか意味がない helper は、top-level の非 export 関数ではなく composable / factory の内部関数として閉じる。

export するのは次のような symbol に絞る。

  • feature / shared slice の公開 API として UI や他 module から使う composable、factory、model、utility。
  • 複数 module から再利用され、名前・型・振る舞いを安定した contract として扱いたい関数。
  • 単体で複雑な仕様を持ち、公開 contract として直接 test する価値がある pure function。
  • TypeScript の型として module 境界で必要な type / schema / interface。

scope の優先順位は次の通り。

  1. 処理が短く、名前を付けても意味が増えないなら呼び出し箇所に直書きする。
  2. composable / factory 内の文脈でしか意味がないなら内部関数にする。
  3. 同じ file 内の複数箇所から使う、または純粋な domain helper として読む方が自然なら top-level const の非 export にする。
  4. 他 module へ提供する安定した contract なら export する。

避ける書き方:

ts
export const resolveRoomJoinInput = ({
  room,
  user,
  displayName,
}: ResolveRoomJoinInput) => ({
  roomId: room.roomId,
  user,
  displayName: normalizeDisplayName(displayName),
})

export const resolveRoomJoinUser = ({
  result,
  user,
}: ResolveRoomJoinUserInput) => {
  if (!result.ok || !user) {
    return undefined
  }

  return user
}

このような「処理途中の整形」「分岐の一部」「composable 内の都合で生まれた helper」は、外部利用の意図がない限り非 export にする。 さらに、次のような薄い分岐は関数化せず、呼び出し箇所に直書きした方が読みやすいことが多い。 条件を満たさない場合にその場で抜けるだけの guard clause は、短ければ single-line return を使ってよい。

ts
if (!roomId) return
if (!result.ok) return

return user

名前を付けた関数にすると、読み手は「再利用される domain rule なのか」「副作用や追加条件があるのか」を確認しに行く必要がある。 条件と戻り値がその場で十分に読める場合は、無理に helper 化しない。 ただし、return value がある、条件や副作用が長い、logging や cleanup など複数 statement が必要な場合は block にする。

top-level helper として残す価値がある場合の書き方:

ts
const resolveRoomJoinInput = ({
  room,
  user,
  displayName,
}: ResolveRoomJoinInput) => ({
  roomId: room.roomId,
  user,
  displayName: normalizeDisplayName(displayName),
})

composable 内の文脈でしか使わない場合は、さらに scope を狭める。

ts
export const createRoomJoin = (dependencies: RoomJoinDependencies) => {
  const joinRoom = async (room: Room, displayName: string) => {
    const result = await dependencies.joinRoom({
      roomId: room.roomId,
      displayName: normalizeDisplayName(displayName),
    })

    if (!result.ok || !result.data.user) return undefined

    return result.data.user
  }

  return {
    joinRoom,
  }
}

unit test は、まず公開 API 経由で振る舞いを検証する。 実装詳細の helper を直接 test するためだけに export しない。 helper の仕様が大きくなり公開 contract として扱う価値が出た場合は、責務に合う module へ移し、命名・型・test を公開 API として整える。

Vue and Test Code

Vue component の <script setup lang="ts">、composable、Vitest test でも同じ rule を適用する。

  • <script setup lang="ts"> 内の handler は arrow function にする。
  • computed の getter、watch callback、event handler、Vitest の describe / it callback も arrow function にする。
  • Vitest の beforeEachafterEachbeforeAllafterAll など lifecycle callback も arrow function にする。
  • component 内に domain logic を溜めず、純粋関数または composable に切り出す。
  • refreactive の更新は UI state の境界として扱い、計算可能な値は computed または純粋関数に寄せる。
  • test helper は、複数 test で意味のある前提を共有する場合だけ切り出す。1 箇所だけの薄い arrange を helper 化しない。
  • mock や fixture を外部から使わせる意図がないなら export しない。

Vue / Nuxt の配置、feature slice、i18n、Result 型、Zod validation などの横断ルールはこの rule の対象外とする。 この rule は TypeScript としての書き方、scope、pure / impure の分離に集中する。

Review checklist

TypeScript code をレビューするときは次を確認する。

  • let がある場合、const、早期 return、関数分割、配列メソッドで置き換えられないか。
  • function 宣言、function 式、method shorthand、method signature がある場合、arrow function / property function type にできないか。
  • 入力から出力を作るだけの処理が、Vue state や外部 API と混ざっていないか。
  • 実装詳細の helper が、外部利用の意図なく export されていないか。composable 内部や直書きで済む scope ではないか。
  • 短い条件分岐や単純な return を、可読性が上がらないまま helper 化していないか。
  • 現在時刻、乱数、network、storage、router、Firebase などの副作用境界が注入・分離されているか。
  • test しやすい pure core と、薄い impure shell に分かれているか。
  • Vue component と test code の callback も、この rule から外れていないか。