1766884767
2025-12-28 00:05:00
銀行、通信、決済では、信頼性があることは良いことではありません。それはテーブルステークスです。私がこれまでに取り組んできた最も信頼性の高いシステムは、コードが実行される前に、あらゆる種類のバグを軽減します。関数型プログラミングと代数データ型 (ADT) を使用すると、正確さを型システムに組み込むことができるため、そもそも不正な状態を構築できなくなります。
何を学ぶか
- 無効な状態が実際のシステムでどのように現れるか、そしてそれがコストのかかるインシデントを引き起こす理由
- ADT がビジネス ルールをエンコードしてコンパイラに適用する方法
- パターンマッチングと網羅性チェックがリファクタリングを安全な編集に変える仕組み
- TypeScript と OCaml での銀行および通信ドメインの実践的なモデリング パターン
- ジュニアおよび中級レベルが今すぐ適用できる移行ハンドブック
参考文献
1) 信頼性は型システムから始まります
本番環境のインシデントのほとんどは、複雑なアルゴリズムが原因ではありません。これらは、コードが本来あり得ない状態に入ることが原因です。オンコールを行ったことがある場合は、次のようなバリエーションを見たことがあるでしょう。
- マジックストリング:
"paypal"のみをサポートするシステムに侵入するCash、Card、Pix - Null: 関数は電子メールを予期し、受信します。
null守り忘れた道で - 矛盾するブール値: アカウントは両方です
isActive = trueそしてisSuspended = true - 不完全なライフサイクル: トランザクションがマークされています
Pendingそして次にジャンプしますReversed関連なしでSettled記録
関数型プログラミングは、無効な状態を表現できない型でドメインをモデル化することで役立ちます。純粋な関数と不変性により、動作の予測とテストが可能になります。
2) 実際の ADT: 和と積
製品タイプはフィールドを結合し、「and」を考えます。合計タイプは、いくつかのケースから 1 つを選択し、「または」と考えます。これらは一緒にドメイン ルールをモデル化します。
製品タイプ例
(* OCaml *)
type user = {
id : int;
name : string;
email : string option; (* Some "[email protected]" or None *)
}
// TypeScript
type User = Readonly;
合計型の例
(* OCaml *)
type payment =
| Cash
| Card of string (* last 4 digits *)
| Pix of string
// TypeScript discriminated union
type Payment =
| { kind: "cash" }
| { kind: "card"; last4: string }
| { kind: "pix"; key: string };
この形だと、 "paypal" として存在することはできません Payment。コンパイラは値を拒否します。
3) パターンマッチングと網羅性
sum 型でパターン マッチを行う場合、コンパイラはすべてのバリアントを処理することを強制する可能性があります。後で新しいケースを追加すると、完全に一致しないすべての一致がコンパイル エラーまたは警告になります。これは、リファクタリングがデフォルトで安全になる方法です。
(* OCaml *)
let describe_payment = function
| Cash -> "Paid in cash"
| Card last4 -> "Card ••••" ^ last4
| Pix key -> "Pix " ^ key
// TypeScript
const assertNever = (x: never): never => { throw new Error(`Unhandled variant: ${JSON.stringify(x)}`) }
function describePayment(p: Payment): string {
switch (p.kind) {
case "cash": return "Paid in cash"
case "card": return `Card ••••${p.last4}`
case "pix": return `Pix ${p.key}`
default: return assertNever(p)
}
}
新規追加 Crypto メソッドと両方のコード ベースは、更新する必要があるすべての場所を示します。
4) 障害シナリオとそのタイプに基づく修正
4.1 銀行の例: 二重決済と調整ドリフト
事件の話
支払いワーカーがネットワークタイムアウトで再試行し、呼び出しを行う settle() 2回。テーブルで許可されるのは、 pending = false そして settled = true 同じ台帳 ID で 2 回。調整により重複が検出され、会計処理は手動で修正する必要があります。
なぜそうなったのか
状態はブール値と文字列にまたがります。データベースはライフサイクルを表現しません。アプリケーション コードには含まれていますが、それは慣例とテストによるものに限られます。
ADT で修正する
(* OCaml *)
type failure_reason =
| InsufficientFunds
| ComplianceHold
| NetworkError of string
type txn_state =
| Pending
| Settled of string (* ledger_id *)
| Failed of failure_reason
| Reversed of string (* original_ledger_id *)
type txn = {
id : string;
amount_cents : int;
state : txn_state;
}
// TypeScript
type FailureReason =
| { kind: "insufficientFunds" }
| { kind: "complianceHold" }
| { kind: "networkError"; message: string }
type TxnState =
| { kind: "pending" }
| { kind: "settled"; ledgerId: string }
| { kind: "failed"; reason: FailureReason }
| { kind: "reversed"; originalLedgerId: string }
type Txn = Readonly
遷移は全体的な関数になります。返品できます Result 移行が許可されない場合。
(* OCaml *)
type 'a result = Ok of 'a | Error of string
let settle (t: txn) (ledger_id: string) : txn result =
match t.state with
| Pending -> Ok { t with state = Settled ledger_id }
| Settled _ -> Error "already settled"
| Failed _ -> Error "cannot settle a failed transaction"
| Reversed _ -> Error "cannot settle a reversed transaction"
// TypeScript
type Ok = { _tag: "Ok"; value: T }
type Err = { _tag: "Err"; error: E }
type Result = Ok | Err
const Ok = (value: T): Result => ({ _tag: "Ok", value })
const Err = (error: E): Result => ({ _tag: "Err", error })
function settle(t: Txn, ledgerId: string): Result {
switch (t.state.kind) {
case "pending": return Ok({ ...t, state: { kind: "settled", ledgerId } })
case "settled": return Err("already settled")
case "failed": return Err("cannot settle a failed transaction")
case "reversed": return Err("cannot settle a reversed transaction")
}
}
現在、違法な移動は建設によってブロックされています。テスト カバレッジは依然として重要ですが、モデルの形状により、ある種のバグが防止されます。
安全性をリファクタリングする
商品追加時 Chargeback、コンパイラはそれを無視するすべての一致を強調表示します。ライフサイクルを半分に処理した状態で出荷することはできません。
type TxnState =
| { kind: "pending" }
| { kind: "settled"; ledgerId: string }
| { kind: "failed"; reason: FailureReason }
| { kind: "reversed"; originalLedgerId: string }
| { kind: "chargeback"; networkRef: string } // new
毎 switch の上 TxnState 今必要なのは chargeback 支店。これはコンパイラからの無料のガイダンスです。
4.2 通信の例: ゴースト請求と不完全なセッション
事件の話
通話詳細記録パイプラインは、 Connected イベント。ジッターと再試行により、一部のセッションが受信しない Completed。請求システムでは間違った境界線に基づいて料金が請求され、顧客から苦情が寄せられています。
なぜそうなったのか
呼び出しのライフサイクルは、多くのサービスにわたって暗黙的に行われます。課金不可能な状態と課金可能な状態を区別するタイプがなかったため、終了のない接続されたセッションでも引き続き課金可能でした。
ADT で修正する
(* OCaml *)
type drop_reason = Network | Busy | Timeout
type call =
| Dialing of int (* at_ms *)
| Connected of int (* started_ms *)
| Dropped of drop_reason * int (* reason, at_ms *)
| Completed of int * int (* started_ms, ended_ms *)
let billable_seconds = function
| Completed (start_ms, end_ms) -> max 0 (end_ms - start_ms) / 1000
| _ -> 0
// TypeScript
type DropReason = "network" | "busy" | "timeout"
type Call =
| { kind: "dialing"; atMs: number }
| { kind: "connected"; startedMs: number }
| { kind: "dropped"; reason: DropReason; atMs: number }
| { kind: "completed"; startedMs: number; endedMs: number }
const billableSeconds = (c: Call): number => {
switch (c.kind) {
case "completed": return Math.max(0, (c.endedMs - c.startedMs) / 1000)
default: return 0
}
}
現在、接続されているが完了していない通話では、請求対象期間を生成できません。その形状はバグを防ぎます。
4.3 構成解析の例: 隠れた NaN と部分的な失敗
事件の話
キャッシュ TTL は環境変数に保存されます。誰かが設定する CACHE_TTL_SECS=30s。 JavaScriptでは、 Number("30s") 収量 NaN コードではそれをゼロとして扱い、運用環境でのキャッシュを無効にします。
結果タイプを修正する
(* OCaml *)
type 'a result = Ok of 'a | Error of string
let parse_int (s: string) : int result =
try Ok (int_of_string s) with _ -> Error "invalid int"
let load_ttl () : int option =
match Sys.getenv_opt "CACHE_TTL_SECS" with
| None -> None
| Some s ->
match parse_int s with
| Ok n -> Some n
| Error _ -> None (* or propagate the error *)
// TypeScript
const parseIntR = (s: string): Result =>
/^-?d+$/.test(s) ? Ok(Number(s)) : Err("invalid-int")
function loadTtl(): Option {
const raw = process.env.CACHE_TTL_SECS
if (!raw) return None
const parsed = parseIntR(raw)
return parsed._tag === "Ok" ? Some(parsed.value) : None
}
曖昧さが消えます。コードは不在を処理し、エラーを明示的に解析する必要があります。
5) 最高級のツールとしてのオプションと結果
使用しないでください null 「もしかしたら」という意味。予想されるエラーに対して例外をスローしないでください。
オプションまたは多分
(* OCaml *)
type user = { id: int; email: string option }
let send_mail (u: user) =
match u.email with
| Some e -> (* send e *) ()
| None -> ()
// TypeScript
type None = { _tag: "None" }
type Some = { _tag: "Some"; value: T }
type Option = None | Some
const None: None = { _tag: "None" }
const Some = (value: T): Option => ({ _tag: "Some", value })
const map = (o: Option, f: (a: A) => B): Option =>
o._tag === "Some" ? Some(f(o.value)) : None
結果またはどちらか
(* OCaml *)
let result_map f = function
| Ok x -> Ok (f x)
| Error e -> Error e
// TypeScript
type Ok = { _tag: "Ok"; value: T }
type Err = { _tag: "Err"; error: E }
type Result = Ok | Err
const Ok = (value: T): Result => ({ _tag: "Ok", value })
const Err = (error: E): Result => ({ _tag: "Err", error })
これらのタイプは、ハッピー パスとエラー パスを同様に明示的にします。
6) 不変性とそれが推論を単純化する理由
可変共有状態は、同時実行下でのハイゼンバグの一般的な原因です。不変データと純粋な関数を好みます。更新する必要がある場合は、新しい値を作成します。
(* OCaml *)
let set_email (u: user) (e: string option) : user =
{ u with email = e }
// TypeScript
type User = Readonly }>
const setEmail = (u: User, email: Option): User =>
({ ...u, email })
テストが簡単になります。同じ入力が与えられると、関数は同じ出力を返します。
7) ユニットと不変条件を保護するスマート コンストラクターとニュータイプ
数字はそれ自体を説明するものではありません。意味のある型を作成します。
(* OCaml *)
module Cents : sig
type t
val make : int -> t (* reject negatives *)
val add : t -> t -> t
end = struct
type t = Cents of int
let make x = if x
// TypeScript
type Brand = K & { __brand: T }
type Cents = Brand
const Cents = (n: number): Cents => {
if (!Number.isInteger(n) || n
const Millis = (n: number): Millis => n as Millis
const price: Cents = Cents(500)
// const wrong: Cents = Millis(500) // type error
ミリ秒と秒を間違えたり、ドルとセントを誤って混ぜたりすることはなくなります。
8) 純粋なコア、効果的なシェル
ドメイン ロジックを純粋に保ち、IO をエッジまでプッシュします。これにより、単体テストが安価かつ高速になります。
(* OCaml, pure core *)
let authorize_payment (amount: Cents.t) (balance: Cents.t) : bool =
amount
// TypeScript, pure core
const authorizePayment = (amount: Cents, balance: Cents): boolean => {
return (amount as unknown as number)
9) 移行プレイブック
小さく始めて継続的に進歩してください。これらのアイデアを初めて使用するチームのための実際的な順序を次に示します。
-
ブール値のペアを合計型に置き換えます
- 前に:
{ isActive: boolean; isSuspended: boolean } - 後:
type AccountState = { kind: "active" } | { kind: "suspended" } | { kind: "closed" }
- 前に:
-
文字列列挙型を識別共用体に置き換えます
- 次のような自由形式の文字列は避けてください。
"pending" | "settled" | "failed" | string
- 次のような自由形式の文字列は避けてください。
-
Null 許容フィールドをオプションで置き換える
- OCaml
string option - TypeScript
Optionまたはより厳密なドメイン固有の結合
- OCaml
-
スローされた制御フローを結果で置き換えます
- 本当に予期しない状況に備えて例外を予約する
-
ユニットと ID にニュータイプまたはブランド型を導入する
- 混合を防ぐ
MillisとSeconds、CentsとDollars
- 混合を防ぐ
-
徹底的な徹底
- OCaml はすでに警告しています
- TypeScript: 区別された共用体、
assertNever、strictNullChecks、noImplicitReturns
-
CI にガード レールを追加する
- TypeScript の非網羅的なスイッチと OCaml の警告をエラーとして扱う
探すべき匂い
- 同時に true になり得る複数のブール値
- 検証される前にずっと転送される文字列
- 値を返すこともあればスローすることもある関数
- 単位のない数値を運ぶ型
10) パフォーマンスと人間工学
パターン マッチングは単純な分岐にコンパイルされます。 TypeScript の識別共用体は単なるプレーン オブジェクトです。あなたが感じる主なコストは、スマート コンストラクターの境界での検証です。これは価値のある取引だ。その後、コンパイラはシステムの内部を保護します。
結論
信頼性が設計されています。代数データ型、パターン マッチング、オプションと結果、不変性、スマート コンストラクターを使用して、型内にドメイン ルールを直接エンコードします。不正な状態はコンパイルできません。銀行や通信など、失敗が許されない業界が機能的なアイデアに惹かれるのはこのためです。
お金、議事録、公開に関わるコードに取り組んでいる場合は、今すぐこれらのパターンを採用してください。
- 競合するフラグではなく、合計タイプを使用してワークフローをモデル化する
- null 可能およびスロー可能な API の代わりにオプションと結果を使用する
- 純粋なコアを維持し、エッジにエフェクトを加える
- ブランド単位と識別子
- コードレビュー中およびCI内で網羅性を強化する
オンコールシフトはより静かになり、ユーザーは違いに気づくでしょう。
さらに読む
付録: 準備ができたヘルパーを貼り付ける
// TypeScript Option and Result
export type None = { _tag: "None" }
export type Some = { _tag: "Some"; value: T }
export type Option = None | Some
export const None: None = { _tag: "None" }
export const Some = (value: T): Option => ({ _tag: "Some", value })
export type Ok = { _tag: "Ok"; value: T }
export type Err = { _tag: "Err"; error: E }
export type Result = Ok | Err
export const Ok = (value: T): Result => ({ _tag: "Ok", value })
export const Err = (error: E): Result => ({ _tag: "Err", error })
export const assertNever = (x: never): never => { throw new Error(`Unhandled variant: ${JSON.stringify(x)}`) }
(* OCaml Option and Result helpers *)
let option_map f = function
| None -> None
| Some x -> Some (f x)
let option_value ~default = function
| None -> default
| Some x -> x
let result_map f = function
| Ok x -> Ok (f x)
| Error e -> Error e
#信頼性に機能的なプログラミングが必要な理由 #ADT安全性重要なインフラストラクチャ