エンジニアの退職は「人の問題」ではなく「意思決定プロセスの問題」

近年、プロダクト部門はビジネスの成果を左右する重要な部門となっており、その組織を構成するエンジニアの質がビジネスの成果に大きく影響するようになっています。一方でエンジニアの離職率の高さと採用の難しさが経営の問題となっている組織も少なくありません。

この記事では、なぜエンジニアがやめるのかをエンジニアの心理と構造の点から解説し、組織がとるべき対策について論じます。

1.退職の理由

まず前提として、エンジニアはその会社をできる限り続けたいと考えています。
なぜなら、転職にはリスクを伴うからです。新しい組織に移動すると今までの人間関係がリセットされ、ゼロから関係を築いていく必要があります。そういった苦労はできれば避けたいと考えています。

それなのに、なぜやめてしまうのでしょうか。給料が安い、残業が多い、技術的な学びが少ない、福利厚生が乏しい。どれもそれらしい理由に見えますが、実はこういった問題は本質的な問題ではありません。

エンジニアが退職する根本的な理由は、その会社や組織がどうにもならないと判断したからです。多くのエンジニアは退職以外に現状を改善する手段がなくなったため、退職しているのです。

2.静かな危険信号

以前は意見を言っていたのに、最近静かになったと感じる人はいませんか?最も退職のリスクが高い人は最近静かになった人です。

文句・反論・衝突は「改善の余地がある」というサインです。声を上げている人は、まだ組織に期待をしている状態です。その期待がなくなった時、不満を表に出さなくなります。

沈黙は判断の結果

エンジニアが不満を表に出さない理由は、単に我慢しているからではありません。声を上げるコストと、それによって得られる利益を天秤にかけた結果、声を上げることをやめているのです。黙るのは「合理的撤退判断」です。

論理的思考の強いエンジニアは、感情的な反応ではなく、冷静な判断に基づいて行動します。組織が変わる可能性が低いと判断した時点で、声を上げることをやめ、次のステップを考え始めます。

3.なぜ組織は「沈黙」を危険信号として認識できないのか

このように「沈黙」は危険信号ですが、多くの組織は危険信号として認識することができていません。これは沈黙を計測するのが難しいという構造上の理由からです。

表面上は何も問題が起きていないように見える

納期は守られています。いつもと変わらず仕事は進んでいるように見えます。表面上は何も問題が起きていないように見えるため、組織は沈黙を危険信号として認識できません。

マネジメントが見ている指標がズレている

マネジメントは数値化できる指標、例えば、アウトプット量・稼働率を見ています。意思決定の質については計測することが難しく、現場レベルでの感覚でしかわからないことも多いです。

このズレが、沈黙を見逃す原因となっています。沈黙はKPIに現れません。エンジニアがやめる時、組織は初めて問題に気づくのです。

4.問題は「意思決定プロセス」

声を上げることをやめる原因の多くは、「意思決定プロセス」にあります。反対意見を言うことにリスクがあったり意味を持たない状態が続くと、エンジニアは意見を言うことをやめます。例えば、

  • 反対意見を言ってもその場で突っぱねられる
  • 意見の違いに寛容ではない(意見を言うこと自体がリスクになる)
  • その場では検討しますと言われるが、実際は検討されない

意見 → 何も変わらない、の繰り返しが続くと、発言コストだけが蓄積していきます。この状態が続くと、エンジニアは「この組織では言っても無駄だ」と判断し、意見を言うことをやめます。

以下は私が実際に観察した問題のある意思決定プロセスです。

ファシリテーターが意見を評価する

ある開発チームの会議で、開発チームのリーダーが会議をファシリテーションしたのですが、そのファシリテーションに問題がありました。

開発チームのリーダーはメンバーが意見を言ったその場でその意見を評価し、その内容からはそれはちょっと違うというようなニュアンスが感じられました。そのような反応をされてしまうと、メンバーは正直に自分の意見を言うことが難しくなります。

ファシリテーターは参加者の発言を促し、会議を前に進めることが目的です。ファシリテーター自身は意見を評価するべきではありません。ファシリテーターが意見を評価すると、ファシリテーターの顔色を伺いながらメンバーが発言をすることになり、意見を言うことを躊躇したり、意見を忖度することになるからです。

このように誰かの顔色を伺いながら発言しないといけない状態は、意思決定プロセスに問題があると言えるでしょう。

意見の違いに寛容ではない(意見を言うこと自体がリスクになる)

意見の違いに寛容でないことは長期的にリスクがあります。短期的には意思決定の速度が上がりますが、メンバーが考えることをやめてしまい、長期的には組織としての意思決定力が弱くなってしまうからです。

このような事例がありました。

ある会議で開発者の一人がシステム的な問題について意見を述べているところ、リーダーがその話を遮り、問題は別にあるということを話し始めました。話を遮られたメンバーは自分の意見が尊重されていないと感じるでしょう。

リーダーとメンバーのような構造はリーダーの意見が構造上強くなります。リーダーがメンバーの人事権を持っていることが多いためです。
リーダーは組織の構造的な力学を理解し、幅広い意見を収集するという意識を持つ必要があります。しかし、そのような視点を持っているリーダーは多くありません。特にプレイヤーとして優秀だった人がリーダーになった場合は、自分の意見が正しいという意識が強い傾向にあります。

本来は様々な観点から問題を分析する必要がありますが、誰かと異なる観点で発言をした時に話を遮られてしまうような状態は、意思決定プロセスに問題があると言えるでしょう。

5.沈黙の代償は、想像よりも高い

意思決定プロセスのまずさは次のような問題を引き起こします。

エンジニアの退職

黙っている人はある日突然やめていきます。さらに悪いことに優秀なエンジニアからやめていきます。なぜなら、エンジニアは売り手市場であり、優秀なエンジニアであればよい条件で次のポストを見つけることができるからです。
優秀なエンジニアは「代替が効きにくい人」でもあります。特に

  • ドメイン知識
  • 設計思想
  • 暗黙知

これらを持っている人がやめると、組織は大きな損失を被ります。

意思決定の負債の蓄積

技術負債という言葉がありますが、技術負債が発生する根本原因には意思決定の負債があります。ソフトウェアは設計思想がなくなると、途端に乱れます。設計思想は暗黙知として共有されていることが多く、設計思想の継承がされない状態でエンジニアがやめてしまうと次のような問題が顕在化します。

  • なぜその設計だったのか分からない
  • 誰も判断できず、決定が遅くなる
  • 場当たり的なコードが増え、開発速度や品質が低下する

意思決定の負債が発生(根本原因)→場当たり的なコードで対応(原因から引き起こされる結果)→場当たり的なコードが蓄積した結果、技術負債が発生(表面に見える現象)というのが技術負債の構造です。

沈黙が組織文化に与える長期的な影響

沈黙は個人の問題ではなく、組織文化の問題として広がっていきます。優秀なエンジニアが黙り始めると、その影響は組織全体に波及します。

残ったエンジニアは「意見を言っても無駄だ」という文化を学習し、自分も意見を言わなくなる傾向があります。このように、沈黙は組織文化として定着し、長期的に組織の意思決定力を弱めていきます

また、心理的安全性が低下することで、エンジニアはリスクを取らなくなり、イノベーションが生まれにくくなります。組織は現状維持に留まり、競争力が低下していくことになります

6.経営者・事業責任者・リーダーが取るべき視点

まず、意思決定のプロセスのまずさがエンジニアの退職を引き起こしていると認識することが第1歩です。
意思決定のプロセスについてはリーダーの影響が大きいため、現場のエンジニアレベルでの改善は難しいということを理解する必要があります。

意思決定のプロセスは一見すると人の問題に見えますが、「人の問題ではなく、構造の問題」です。本質的な問題は意思決定のプロセスが整備されていないところにあります。構造の問題は水が高いところから低いところに流れるように、再現性を持って繰り返されます。そのため、新しく人を入れても数年で離職ということを繰り返すことになります。

沈黙を生まない組織設計が必要

意思決定のプロセスの成否は仕組みとマネジメント層の適切な運用にかかっています。意見を言いやすい環境づくりをすることで、エンジニアが意見を言い続けられる環境を作る必要があります。

意見を言いやすい環境づくり

意見を言いやすい環境を作るためには、以下のような取り組みが有効です。

  • 意見を言うこと自体を評価する: 意見の内容が採用されなくても、意見を言ったこと自体を評価することで、意見を言いやすい環境を作ることができます
  • 意見の違いに寛容である: 意見の違いは組織の多様性であり、長期的な組織力の源泉です。意見の違いに寛容であることで、多様な意見が出やすくなります
  • 意見を言うリスクを下げる: 意見を言うことで不利益を被るリスクがあると、意見を言いにくくなります。意見を言うこと自体がリスクにならないような環境を作ることが重要です

定期的なフィードバックの仕組み

意思決定プロセスが適切に機能しているかを定期的に確認する仕組みを作ることも重要です。例えば、定期的に「意見を言いやすい環境かどうか」をアンケートで確認したり、1on1で意見を聞いたりすることで、問題を早期に発見できます。

沈黙を生まない組織設計は、一度作れば終わりではありません。継続的に改善していく必要があります。組織の状況に応じて、適切な仕組みを調整していくことが重要です。

おわりに

「沈黙は同意を意味しない」という言葉があります。同意のない沈黙は組織の危険シグナルです。組織はそこで働く人が建設的な意見が言えるように意思決定プロセスを作り、適切に運用する必要があります。そうでなければ、優秀な人材を失い、組織の競争力が低下することになるでしょう。

この記事が、あなたの組織でも起きているかもしれない問題に気づくきっかけになれば幸いです。

2025年の振り返り|フリーランス1年目に学んだ、判断・構造・距離感

はじめに

2025年という1年が、静かに終わりへと向かっています。今年はフリーランスとして本格的に活動を始めた年であり、技術的にも個人的にも大きな変化があった1年でした。今年1年の技術面での学び、成長を感じた点を振り返り、少し先の未来に向けた展望を整理したいと思います。

技術面での学び

Go / ReactによるマルチテナントSaaSの開発

2025年は開発に集中をした1年となりました。
ここ数年のキャリアはマネジメント中心であったり、開発をしつつチームビルディングを行うことが多かったのですが、マネジメント方面の仕事はほとんどやらず、開発や設計についてじっくり腰を据えて取り組むことができました。
技術スタックは、バックエンドがGo/Node.js、フロントエンドがReactです。
マルチテナントSaaSの開発に携わることができ、マルチテナントSaaSの設計について開発を通して理解することができました。

AIを使った開発

この1年で開発方法が大きく変わりました。従来の人間が全てを行う開発からAIを活用した開発がこの1年で大きく変わったポイントです。私の18年のキャリアの中で最も大きな変化だと感じています。
コーディングだけでなく、仕様の調査や設計、コードレビューなど多くの領域でAIを使うようになりました。
AIによる開発は今までの人による開発と比べて開発速度、品質ともに向上すると断言できます。
一方でAIは万能ではなく、最終的な品質を決めるのは人間の判断であることも同時に学びました。
こういったことを知識ではなく、現場で肌で感じれたことが貴重な経験となりました。

「判断」「構造」「距離感」の学び

技術的に収穫のあった1年となりましたが、特に成長として実感していることは技術そのものというより、仕事の進め方や思考の質が変わったことにあると考えています。特に「判断」「構造」「距離感」の3つのポイントについては言語化できるレベルで明確な学びがありました。

判断|状況に応じた判断と振る舞い

以前は「自分で判断しなければならない」「自分が決めるべきだ」という思いが強く、時としてまわりとの衝突に繋がってしまう恐れがありました。

あらためて、いちプレイヤーとして働くようになり、それに伴い仕事での観察や内省を通して、

  • 判断に必要な情報を整理して提示する
  • メリット・デメリットやリスクを言語化する
  • 最終的な判断は相手に委ねる

という進め方のほうがスムーズにいくのではないかと考えました。


結果として、

  • まわりとの摩擦が減る
  • エネルギーを無駄に消耗しない
  • 決定者が明確になり意思決定の速度が上がる

といった効果が生まれ、仕事がスムーズに進むようになったと感じています。
主体性がなくなったのではないかとの懸念を持たれるかもしれませんが、「判断を委ねること」に価値を置けるようになったということが本質的な変化であり、状況に応じて適切な判断や振る舞いができるようになったのではないかと思います。

構造|仕組みに目を向ける

問題を「構造の問題なのか、個人の問題なのか」で切り分けて考えられるようになったことが以前と比べた大きな違いです。
以前は、現場で起きる違和感やトラブルに対して「能力や姿勢の問題ではないか」「自分が何とかすべきではないか」と考え、解決の難しい領域にエネルギーを消耗してしまうことがありました。

現在は構造に問題がないかという観点で捉えるようになりました。
具体的には次のように考えます。

  • 役割設計や責任範囲に歪みはないか
  • 意思決定やレビューの仕組みに不備はないか
  • ルールや前提が適切に共有されているか

構造の問題に対しては、前提や仕組みを少し変えるだけで、再発防止や影響範囲の縮小といった効果的なアプローチが取れることを学びました。
一方で、個人の能力や性格、価値観に起因する問題については、それが自分の立場で解決できる領域なのかを判断し、自分が介入すべきでないものには介入しないと割り切れるようにもなりました。
このように問題の対象について識別することで、より効果的に時間とエネルギーを使えるようになったと思います。

距離感|適切な余白を保つ

仕事との間に、適切な余白を置けるようになったことも、この1年での変化です。
距離感が近いと相手や組織への期待であったり、その期待とのギャップが生じます。
期待とのギャップからはネガティブな感情が生じやすく、不要にエネルギーを消耗してしまい、安定したパフォーマンスを発揮するのが難しくなってしまいます。
安定したパフォーマンスを発揮するには、適度な距離感を持つほうがよいことがわかってきました。


また、コードレビューへの向き合い方にも変化がありました。 以前は、コードレビューで多くの指摘が入ると少なからず気持ちが揺れていましたが、こちらについても感情とは切り離して淡々と対応することができるようになりました。 この変化はAIによるコーディングができるようになり、対応コストが下がったことも影響していると考えています。

この距離感が身についたことで、不要にエネルギーを消耗せず、安定して価値を出し続けられる感覚を持てるようになりました。

以上のような変化は正社員→フリーランスへの立場の変化とAIによる開発手法の変化が影響していると思っており、継続的にポジティブな効果をもたらすと考えています。

これからについて

この1年間を通して、自分の強みは「実装力」そのもの以上に、構造の問題を識別しそれをどう解決するかを考えられることにあると、よりはっきりしてきました。

なぜそれができるのかというと、18年間の現場経験の中で、さまざまなチーム・プロダクト・フェーズを見てきたことで、何が機能しやすく、何が形骸化しやすく、どこで現場が詰まりやすいのかを肌感覚として理解できているからだと考えています。
理論として正しいかどうかだけでなく、「この現場で本当に回るか」「いま導入すべきかどうか」といった観点で判断できることは、これまでの経験が積み重なった結果です。

こういった自分の強みがわかってきたため、今後はより包括的な技術判断が求められる仕事に携わっていきたいと考えています。
現場で手を動かしつつも、プロダクトやチームが前に進むための「判断」と「構造」に関わる役割を担うことで、より大きな価値を提供していければと思います。

おわりに

この1年間を振り返ると表面的な飛躍的成長というよりかは内面的に成長したと思える1年でした。技術・働き方・メンタルの一つひとつの学びが深く、実りのある1年だったように思います。


最後に少し抽象的な話しになりますが、今まではどちらかというと燃え盛る炎に象徴されるようなエネルギーや情熱に魅力を感じていましたが、そういったものは一時的で長く続くものではありません。もちろん、時にはそういったエネルギーも必要ですが、今は雪の荒野に静かに立つ常盤木のように静かで持続的な生命力に魅力を感じます。 この常盤木のように静かに、歩みを止めず、持続的に成長していけたらと感じるこの頃です。

今回は1年の振り返りということで個人的な内容となってしまいましたが、同じようにソフトウェアエンジニアやフリーランスとしての働き方に興味がある方の参考になれば幸いです。

TypeScriptで多言語システムの型チェックを実装する

概要

多言語対応は一般的に翻訳関数に翻訳キーを渡すことで実装されます。その際に問題になるのが、キー名の間違い、キー名の変更漏れなどによる実装不備です。このようなエラーを人の目で全て確認するのは難しいため、システムによる検知が有効です。

この記事では、TypeScriptの型システムを活用した、型チェックの方法を説明します。これにより、正しい翻訳キーが割り当てられていることがコンパイル時にチェックされ、アプリケーションの品質向上に大きな効果が期待できます。

多言語システムの実装

1. ベースとなる型の定義

翻訳の型チェックは、日本語のメッセージファイル(ja.ts)をベースとして構築します。翻訳ファイルはJSONファイルで扱われることもありますが、型チェックという観点ではtsファイルで管理するのがおすすめです。

1
2
3
import { jaMessage } from './ja'

export type JaMessage = typeof jaMessage

TypeScriptの機能:typeof演算子

typeof演算子は、値から型を抽出するTypeScriptの機能です。typeof jaMessageにより、jaMessageオブジェクトの構造から型を自動的に生成します。これにより、jaMessageの構造が変更されると、型定義も自動的に更新されます。

ja.tsのサンプルコード

以下は、日本語メッセージファイル(ja.ts)のサンプルです。このファイルが型定義のベースとなります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const jaMessage = {
term: {
next: '次へ',
back: '戻る',
cancel: 'キャンセル',
submit: '送信',
},
pages: {
home: {
title: 'ホーム',
},
about: {
title: '紹介',
},
contact: {
title: 'お問い合わせ',
},
terms: {
title: '利用規約',
},
},
} as const

このファイルでは、as constを使用することで、値がリテラル型として推論されます。これにより、型の精度が向上し、より厳密な型チェックが可能になります。

as constとは

as constは、TypeScriptの「constアサーション」という機能です。オブジェクトや配列にas constを付けることで、以下の効果が得られます。

  1. リテラル型として推論される: 値が具体的なリテラル型(例:'マイページ')として推論されます
  2. 読み取り専用になる: オブジェクトのプロパティがreadonlyになります
  3. 型の精度が向上する: より具体的な型情報が保持されます

as constがない場合

as constがない場合、TypeScriptは値の型を一般的な型として推論します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// as const がない場合
export const jaMessage = {
term: {
next: '次へ',
back: '戻る',
},
}

// 推論される型
// {
// term: {
// next: string; // 一般的な string 型
// back: string; // 一般的な string 型
// }
// }

この場合、typeof jaMessageで取得できる型は、値がstring型として推論されます。

as constがある場合

as constがある場合、TypeScriptは値の型をリテラル型として推論します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// as const がある場合
export const jaMessage = {
term: {
next: '次へ',
back: '戻る',
},
} as const

// 推論される型
// {
// readonly term: {
// readonly next: '次へ'; // リテラル型
// readonly back: '戻る'; // リテラル型
// }
// }

この場合、typeof jaMessageで取得できる型は、値が具体的なリテラル型('次へ''戻る')として推論されます。

なぜas constが必要なのか

翻訳メッセージの型チェックにおいて、as constは以下の理由で重要です。

  1. 型の一貫性: typeof jaMessageで取得した型が、実際の値の構造を正確に反映します
  2. 型の精度: GenericTypeが適用される際、元の構造が正確に保持されます
  3. 読み取り専用の保証: 翻訳メッセージが誤って変更されることを防ぎます

実際の違い

as constの有無による型の違いを確認してみましょう。

1
2
3
4
5
6
7
8
9
// as const がない場合
const message1 = { term: { next: '次へ' } }
type Type1 = typeof message1
// Type1 = { term: { next: string } }

// as const がある場合
const message2 = { term: { next: '次へ' } } as const
type Type2 = typeof message2
// Type2 = { readonly term: { readonly next: '次へ' } }

as constがある場合、nextの型は'次へ'という具体的なリテラル型になります。これにより、型システムがより正確に動作し、翻訳キーの型チェックがより厳密になります。

2. 再帰的な型変換

翻訳メッセージはネストされたオブジェクト構造を持っています。この構造を型として表現するために、GenericTypeというユーティリティ型を使用しています。

1
2
3
4
5
type GenericType<T extends object> = {
[K in keyof T]: T[K] extends object ? GenericType<T[K]> : string
}

export type I18nMessage = GenericType<JaMessage>

TypeScriptの機能:条件型(Conditional Types)と再帰的な型

  • [K in keyof T]: マップ型(Mapped Types)により、オブジェクトの各キーを反復処理します

  • T[K] extends object ? GenericType<T[K]> : string: 条件型により、値がオブジェクトの場合は再帰的にGenericTypeを適用し、そうでない場合はstring型とします

これにより、以下のような構造が正しく型付けされます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
term: {
next: string // 文字列型
back: string
cancel: string
submit: string
},
pages: {
home: {
title: string // 3段階のネストも正しく型付けされる
},
about: {
title: string
},
contact: {
title: string
},
terms: {
title: string
}
}
}

GenericTypeは再帰的に動作するため、何段階のネストでも正しく型付けされます。

3. 各言語ファイルでの型チェック

各言語ファイルでは、I18nMessage型を明示的に指定することで、型チェックが行われます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { I18nMessage } from './types'

export const enMessage: I18nMessage = {
term: {
next: 'Next',
back: 'Back',
cancel: 'Cancel',
submit: 'Submit',
},
pages: {
home: {
title: 'Home',
},
about: {
title: 'About',
},
contact: {
title: 'Contact',
},
terms: {
title: 'Terms',
},
},
}

型チェックの効果

  • 必須キーが欠落している場合、コンパイルエラーが発生します

  • 存在しないキーを追加しようとすると、エラーが発生します

  • 値の型がstringでない場合、エラーが発生します

  • キーのタイポがある場合、エラーが発生します

4. 翻訳関数の型安全な実装

翻訳関数はライブラリを利用してもよいですが、シンプルな翻訳システムであれば自前で実装するのもよいと思います。ここでは、必要最低限の翻訳関数のサンプルを紹介します。

翻訳関数についても型安全を担保する必要があります。型システムを実装するために、Paths型とPathValue型を使用します。

キーパスの型生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type PathsInternal<T> = T extends string
? never
: T extends object
? {
[K in keyof T]: K extends string
? T[K] extends object
? T[K] extends string
? K
: `${K}.${PathsInternal<T[K]>}`
: K
: never
}[keyof T]
: never

export type Paths = PathsInternal<JaMessage>

この型により、'term.next''pages.home.title'のような文字列リテラル型が自動生成されます。

キーパスから値を取得する型

1
2
3
4
5
6
7
8
9
10
11
export type PathValue<T, P extends string> = P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? T[Key] extends object
? PathValue<T[Key], Rest>
: never
: never
: P extends keyof T
? T[P] extends string
? T[P]
: never
: never

この型により、キーパスから対応する文字列型を取得できます。

翻訳関数の実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { I18nMessage, Paths, PathValue } from './types'

export type I18nFunction = <P extends Paths>(
key: P
) => PathValue<I18nMessage, P>

export function createI18n(message: I18nMessage): I18nFunction {
return function <P extends Paths>(key: P & string): PathValue<I18nMessage, P> {
const keys = (key as string).split('.')
let value: any = message

for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k as keyof typeof value]
} else {
throw new Error(`Translation key "${key}" not found`)
}
}

if (typeof value !== 'string') {
throw new Error(`Translation key "${key}" does not point to a string value`)
}

return value as PathValue<I18nMessage, P>
}
}

使用例

翻訳関数の使用例です。

翻訳関数の使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createI18n } from './i18n'
import { jaMessage } from './ja'
import { enMessage } from './en'

// 日本語の翻訳関数を作成
const tJa = createI18n(jaMessage)

// 英語の翻訳関数を作成
const tEn = createI18n(enMessage)

// 使用例
console.log(tJa('term.next')) // '次へ'
console.log(tJa('pages.home.title')) // 'ホーム'
console.log(tJa('pages.about.title')) // '紹介'
console.log(tJa('pages.contact.title')) // 'お問い合わせ'
console.log(tJa('pages.terms.title')) // '利用規約'

console.log(tEn('term.next')) // 'Next'
console.log(tEn('pages.home.title')) // 'Home'
console.log(tEn('pages.about.title')) // 'About'
console.log(tEn('pages.contact.title')) // 'Contact'
console.log(tEn('pages.terms.title')) // 'Terms'

正しい使用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const enMessage: I18nMessage = {
term: {
next: 'Next', // ✅ 正しい
back: 'Back',
cancel: 'Cancel',
submit: 'Submit',
},
pages: {
home: {
title: 'Home', // ✅ 3段階のネストも正しい
},
about: {
title: 'About',
},
contact: {
title: 'Contact',
},
terms: {
title: 'Terms',
},
},
}

エラーが発生する例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const enMessage: I18nMessage = {
term: {
next: 'Next',
// ❌ エラー: 'back' プロパティが必須だが欠落している
},
}

// ❌ エラー: 'unknownKey' は存在しないキー
export const enMessage: I18nMessage = {
term: {
next: 'Next',
unknownKey: 'Value', // エラー
},
}

// ❌ エラー: 翻訳関数で存在しないキーを指定
const t = createI18n(enMessage)
t('term.invalidKey') // コンパイルエラー
t('pages.home.invalid') // コンパイルエラー
t('invalid.path') // コンパイルエラー
t('pages.invalid.title') // コンパイルエラー

メリット

  1. コンパイル時の安全性: 翻訳キーのエラーを実行前に検出できます

  2. 自動補完: IDEが翻訳キーを自動補完してくれます

  3. リファクタリングの安全性: キー名を変更する際、使用箇所を自動的に検出できます

  4. ドキュメントとしての役割: 型定義自体が、利用可能な翻訳キーのドキュメントとなります

  5. 翻訳関数の型安全: 翻訳関数を使用する際も、存在しないキーを指定するとコンパイルエラーが発生します

サンプルコード

サンプルコードは https://github.com/shoyan/i18n-sample で公開しています。

Goのインターフェース実装チェックイディオムを理解する

はじめに

Goを書いていると、以下のようなコードを見かけることがあります。

1
var _ Interface = (*Type)(nil)

この一見奇妙なコードは、Goのインターフェース実装をコンパイル時にチェックするためのイディオムです。今回は、このイディオムの目的、必要性、そして適切な使用場面について詳しく解説します。

このイディオムの目的

基本的な仕組み

もう少し具体的なコードで説明してみます。以下のコードは、Counterインターフェースを実装していることをチェックするイディオムです。

1
var _ Counter = (*CountRepository)(nil)

このコードは以下の要素で構成されています:

  • var _ : 空白識別子(blank identifier)を使用した変数宣言
  • Counter : チェック対象のインターフェース
  • (*CountRepository)(nil) : チェック対象の型(nilポインタ)

何をしているのか

このイディオムは、*CountRepository 型が Counter インターフェースを実装しているかをコンパイル時にチェックします。

実際の動作例

正常な場合

以下のコードは Counter インターフェースを実装したサンプルコードです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"context"
"fmt"
)

type Counter interface {
Increment(ctx context.Context, key string) (int, error)
Get(ctx context.Context, key string) (int, error)
Reset(ctx context.Context, key string) error
}

type CountRepository struct {
}

// すべてのメソッドを実装
func (r *CountRepository) Increment(ctx context.Context, key string) (int, error) {
// 実装
return 0, nil
}

func (r *CountRepository) Get(ctx context.Context, key string) (int, error) {
// 実装
return 0, nil
}

func (r *CountRepository) Reset(ctx context.Context, key string) error {
// 実装
return nil
}

// インターフェース実装チェック
var _ Counter = (*CountRepository)(nil)

func main() {
// インターフェース実装チェックのサンプル
repo := &CountRepository{}
ctx := context.Background()

// メソッド呼び出し
count, _ := repo.Increment(ctx, "test")
fmt.Printf("Count: %d\n", count)
}

CountRepositoryはCounterインターフェースで定義してある、Inctement、Get、Resetが実装してあるため、ビルドコマンドでビルドすることができます。

1
go build sample.go

エラーの場合

エラーを発生させるために、Counter インターフェースにDecrementを追加してみましょう。

1
2
3
4
5
6
type Counter interface {
Increment(ctx context.Context, key string) (int, error)
Get(ctx context.Context, key string) (int, error)
Reset(ctx context.Context, key string) error
Decrement(ctx context.Context, key string) (int, error)
}

ビルドすると、ビルドエラーが発生します。インターフェースの未実装が検知されていますね。

コンパイルエラーメッセージ:

1
./sample.go:35:17: cannot use (*CountRepository)(nil) (value of type *CountRepository) as Counter value in variable declaration: *CountRepository does not implement Counter (missing method Decrement)

では、イディオムをコメントアウトしてみるとどうでしょうか。

1
2
// インターフェース実装チェック
// var _ Counter = (*CountRepository)(nil)

ビルドすると、エラーが発生しなくなりました。これは、コンパイル時にインターフェースの未実装が検知されていないことを意味しています。

なぜこのイディオムが必要なのか

このイディオムですが、通常は不要な場合がほとんどです。
例えば、以下のように NewCounter 関数を追加して、CountRepositoryCounter インターフェースであるということを明示的に指定した場合、GOのコンパイラでインターフェースの実装を検知することができます。

1
2
3
4
5
6
7
type CountRepository struct {
}

// Counterインターフェースという型が明示されている
func NewCounter() Counter {
return &CountRepository{}
}

エフェクティブGoの指針

エフェクティブGoでは、このイディオムについて以下のように述べています。

The appearance of the blank identifier in this construct indicates that the declaration exists only for the type checking, not to create a variable. Don’t do this for every type that satisfies an interface, though. By convention, such declarations are only used when there are no static conversions already present in the code, which is a rare event.

重要なポイント

  1. 空白識別子の役割 : 変数を作成せず、型チェックのみを目的とする
  2. 乱用すべきではない : すべてのインターフェース実装に使うべきではない
  3. 静的変換がない場合のみ : 既に型チェックの機会がある場合は不要

使いどころは、静的変換がない場面 です。

静的変換とは

静的変換(static conversion) とは、コンパイル時に型の互換性をチェックする機会のことです。

静的変換がある場合(イディオムは不要)

1
2
3
4
5
6
7
8
9
func Process(repo Counter) {  // ← 静的変換が発生
// 処理
}

func main() {
r := &CountRepository{}
Process(r) // ← ここで型チェックが行われる
// そのため、var _ は不要
}

静的変換がない場合(イディオムが有用)

1
2
3
4
5
6
7
// インターフェースと実装が分離されていて、
// まだ使用箇所がない場合
type CountRepository struct{}

// この時点では静的変換が発生していない
// そのため、明示的に型チェックを行う
var _ Counter = (*CountRepository)(nil)

適切な使用場面

1. リポジトリパターン

1
2
3
4
5
6
7
8
9
10
11
12
13
// domain/repository/user.go
type UserRepository interface {
Get(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
}

// infrastructure/repository/user.go
type UserRepository struct {
db *gorm.DB
}

// リポジトリパターンでは、インターフェースと実装が分離されているため有用
var _ repository.UserRepository = (*UserRepository)(nil)

2. プラグインアーキテクチャ

1
2
3
4
5
6
7
8
9
10
11
// plugin/interface.go
type Plugin interface {
Initialize() error
Execute() error
}

// plugin/example.go
type ExamplePlugin struct{}

// プラグインが正しくインターフェースを実装しているかをチェック
var _ Plugin = (*ExamplePlugin)(nil)

不適切な使用例

既に静的変換がある場合

既に静的変換がある場合はイディオムは必要ありません。

1
2
3
4
5
6
7
// 既に使用箇所がある場合
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}

// この場合、NewUserServiceの呼び出し時に既に型チェックが行われるため
// var _ は不要

まとめ

var _ Interface = (*Type)(nil) イディオムは 静的変換がない場合のみ 使用すればOKです。

コードの複雑度を測る:循環的複雑度(Cyclomatic Complexity)の計算方法と実践ガイド

コードの品質を測る指標として「複雑度」がありますが、特に循環的複雑度(Cyclomatic Complexity)は、コードの保守性やバグの発生リスクを定量的に評価できる指標です。

この記事では、循環的複雑度の計算方法、複雑度を削減する方法、実践的な測定ツールについて、詳しく解説していきます。

循環的複雑度(Cyclomatic Complexity)とは

循環的複雑度は、プログラムの制御フローの分岐の数から算出される複雑度の指標です。この値が高いほど、コードは複雑で理解しにくく、バグが発生しやすくなります。

基本計算式

1
M = E - N + 2P
  • M: 循環的複雑度
  • E: 制御フローグラフの辺の数(edge)
  • N: 制御フローグラフのノード数(node)
  • P: 連結成分の数(通常は1で計算する)

簡易計算方法(現場でよく使われる)

実際の開発現場では、以下の簡易計算方法がよく使われます。

  1. ifforwhilecasecatch などの分岐やループの数を数える
  2. その数に 1 を足す

計算例で理解する循環的複雑度

簡単な関数を例に説明をしていきます。

例1:簡易計算方法

まずは、簡易的な計算方法で関数の複雑度を計算していきましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
function example(x) {
if (x > 0) { // 分岐 1
console.log("A");
} else if (x === 0) { // 分岐 2
console.log("B");
} else {
console.log("C");
}

for (let i = 0; i < 5; i++) { // 分岐 3
console.log(i);
}
}
  • 分岐・ループの数:3
  • 複雑度:3 + 1 = 4

上記の例のように分岐を繰り返しを数えて+1をすれば概算で計算することができます。

例2:基本計算式を使った計算方法

次は M = E - N + 2P を使った計算方法を見ていきましょう。

1
2
3
function helper() {
console.log("do something");
}

制御フローグラフ:
まずは、関数の制御フローグラフを作成します。

1
[Start][print][End]

計算:
次に制御フローグラフからノードの数、辺の数を数えます。

  • N(ノード数)= 3(Start, print, End)
  • E(辺の数)= 2(Start→print, print→End)
  • P(連結成分数)= 1
1
2
3
M = E - N + 2P
M = 2 - 3 + 2*1
M = 1

結果:複雑度 = 1(最低値、まったく複雑ではない関数)

分岐を追加した場合

次に分岐を追加した関数の計算をしてみましょう。

1
2
3
4
5
function helper(flag) {
if (flag) {
console.log("do something");
}
}

制御フローグラフ:

1
2
[Start] → (flag?) ─Yes[print][End]
└─No──────────────→ [End]

計算:

  • N(ノード数)= 4(Start, 条件判定, print, End)
  • E(辺の数)= 4(Start→条件判定, 条件判定→print, 条件判定→End, print→End)
  • P(連結成分数)= 1
1
2
3
M = E - N + 2P
M = 4 - 4 + 2*1
M = 2

結果:複雑度 = 2(分岐を1つ追加しただけで、複雑度は 1 → 2 に増加)

ネストした条件分岐

条件分岐がネストした関数の循環的複雑度を計算をしてみます。

1
2
3
4
5
6
7
function helper(a, b) {
if (a > 0) {
if (b > 0) {
console.log("A and B are positive");
}
}
}

制御フローグラフ:

1
2
3
4
5
6
7
8
9
10
11
[Start] 
|
(a > 0?) ----No----> [End]
|
Yes
|
(b > 0?) ----No----> [End]
|
Yes
|
[print] → [End]

計算:

  • N(ノード数)= 5(Start, 条件判定1, 条件判定2, print, End)
  • E(辺の数)= 6(Start→条件判定1, 条件判定1→End, 条件判定1→条件判定2, 条件判定2→End, 条件判定2→print, print→End)
  • P(連結成分数)= 1
1
2
3
M = E - N + 2P
M = 6 - 5 + 2*1
M = 3

結果:複雑度 = 3

重要なポイント

  • 循環的複雑度は「ネストの深さ」ではなく「分岐の総数」で増える
  • ただし、ネストが深いと認知的複雑度(Cognitive Complexity)はもっと増える
  • 人間はネスト構造を理解するのが苦手なので、Cognitive Complexity では深さもペナルティになる

複雑度の目安

複雑度 意味 対応
1〜10 シンプル、理解しやすい 問題なし
11〜20 複雑、リファクタリングを検討 リファクタリング推奨
21〜50 非常に複雑、バグの温床 分割必須
50+ ほぼ地獄 分割必須

関数呼び出しと複雑度の関係

循環的複雑度の計算は、原則として「関数ごとに独立して」計測するのが基本です。

基本ルール

  • 呼び出している関数の中身はカウントに含めない
  • 関数呼び出しは、それ自体は「分岐」でも「ループ」でもないので複雑度には直接影響しない
  • 複雑度に影響するのは、その関数呼び出しが条件式の一部やループの制御として使われている場合

例1:単純な呼び出し(複雑度に影響なし)

1
2
3
4
5
6
7
function helper() {
console.log("do something");
}

function main() {
helper();
}
  • main の中には分岐もループもなし
  • main の複雑度 = 1(最低値)
  • helper は別に計算(=1)

例2:条件式の中で呼び出し(影響あり)

1
2
3
4
5
6
7
8
9
function isValid(x) {
return x > 0;
}

function main(x) {
if (isValid(x)) { // 分岐1
console.log("ok");
}
}
  • isValid() の中身はカウントしないが、if の分岐1つとして main の複雑度に加算される
  • 複雑度:
    • main = 1(if)+ 1 = 2
    • isValid = 1

複雑度を下げる方法

1. 関数の分割

関数を分割することで1つ1つの関数の循環的複雑度は下がります。

Before(複雑度 = 8)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function complexFunction(data) {
if (data.type === 'A') {
// 処理A
if (data.value > 10) {
// 処理A-1
} else {
// 処理A-2
}
} else if (data.type === 'B') {
// 処理B
if (data.value > 20) {
// 処理B-1
} else {
// 処理B-2
}
}
}

After(複雑度 = 2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function processTypeA(data) {
if (data.value > 10) {
// 処理A-1
} else {
// 処理A-2
}
}

function processTypeB(data) {
if (data.value > 20) {
// 処理B-1
} else {
// 処理B-2
}
}

function complexFunction(data) {
if (data.type === 'A') {
return processTypeA(data);
} else if (data.type === 'B') {
return processTypeB(data);
}
}

2. ポリモーフィズムの活用

ポリモーフィズムを使うことで、分岐がなくなります。それにより、複雑度が下がります。
Before(複雑度 = 6)

1
2
3
4
5
6
7
8
9
function processPayment(payment) {
if (payment.type === 'credit') {
return processCreditCard(payment);
} else if (payment.type === 'debit') {
return processDebitCard(payment);
} else if (payment.type === 'bank') {
return processBankTransfer(payment);
}
}

After(複雑度 = 1):

1
2
3
4
5
6
7
8
9
10
const paymentProcessors = {
credit: processCreditCard,
debit: processDebitCard,
bank: processBankTransfer
};

function processPayment(payment) {
const processor = paymentProcessors[payment.type];
return processor ? processor(payment) : throw new Error('Unknown payment type');
}

測定ツール

ここでは、循環的複雑度の計算方法について説明してきました。
実際の関数は複雑であり、手計算は現実的ではありません。各言語でツールが開発されており、そのツールを利用するのがよいです。

  • JavaScript/TypeScript: ESLint
  • Python: radon
  • Java: SonarQube / PMD
  • Go: gocyclo

まとめ

循環的複雑度は、コードの品質を数値で測れる指標です。チーム開発では「このコードは複雑すぎる」という主観的な意見ではなく、「複雑度が15だからリファクタリングが必要」と客観的に判断できます。複雑度の計算方法を理解すれば、コードレビューでも「なぜこの関数は複雑なのか」を論理的に説明できるようになり、より建設的な議論ができるようになります。

12332