105 karma · joined July 28, 2020
But your take about modern Java is correctly, and they adopt this style under internal projects for some workflows.
- Domain/state machines (payments/fulfillment-style workflows): modeling states + transitions so “impossible” states literally can’t be represented. - Parsers/DSLs & config tooling: log parsers, small interpreters, schema validation, migration planners. - Internal CLIs / automation: batch jobs, release helpers, data shapers, anything you want to be correct and easy to refactor later. - Small backend services when the domain is gnarly (Servant / Yesod style) rather than huge monoliths.
If you’re learning it beyond CS exposure, I’d start with a CLI + a parser (JSON/CSV/logs), then add property-based tests (QuickCheck). That combo teaches types, purity, effects, and testing in one project without needing to “go full web stack” on day 1.
Model states as tagged unions (Union + Literal + dataclass(frozen=True)), use match (Py3.10+) and add assert_never so type checkers complain when you forget a case.
Run Pyright (strict) or mypy –strict in CI so “illegal states” show up as build failures, not incidents.
Validate/parsing at boundaries (HTTP/queues) with Pydantic discriminated unions (tagged unions at runtime), then keep internals typed.
For expected failures, prefer an explicit Result (e.g., returns) over exceptions-as-control-flow.
Use Ruff for lint/consistency (it’s not a type checker, but pairs well with one).
References here:
Pyright: https://microsoft.github.io/pyright/ mypy --strict: https://mypy.readthedocs.io/en/stable/getting_started.html PEP 634 (match): https://peps.python.org/pep-0634/ assert_never & exhaustiveness guide: https://typing.python.org/en/latest/guides/unreachable.html typing_extensions (backports): https://typing-extensions.readthedocs.io/ Pydantic discriminated unions: https://docs.pydantic.dev/latest/concepts/unions/ returns Result: https://returns.readthedocs.io/en/latest/pages/result.html Ruff FAQ: https://docs.astral.sh/ruff/faq/
Two places where I still see tagged/discriminated unions win in practice:
1. Scaling beyond nullability. Once the union has multiple variants with overlapping structure, “untagged” narrowing becomes either ambiguous or ends up reintroducing an implicit tag anyway (some sentinel field / predicate ladder). An explicit tag gives stable, intention-revealing narrowing + exhaustiveness.
2. Boundary reality. In languages like TypeScript (even with strictNullChecks), unions are routinely weakened by any, assertions, JSON boundaries, or library types. Tagged unions make the “which case is this?” explicit at the value level, so the invariant survives serialization/deserialization and cross-module boundaries more reliably.
So I’d summarize it as: T | null is a great ergonomic tool for one axis (presence/absence) when the type system is enforced end-to-end. For domain states, I still prefer explicit tags because they keep exhaustiveness and intent robust as the system grows.
If you’re thinking Scala 3 / a sound type system end-to-end, your point is stronger; my caution is mostly from TS-in-the-wild + messy boundaries.
The reason money-related systems often get singled out is the combination of irreversibility and auditability: a bad state transition can mean incorrect balances/settlement, messy reconciliation, regulatory reporting, and long-tail customer harm that persists after the outage is over.
That said, my point isn’t “finance is special therefore FP.” It’s “build resilience and correctness by design early”, explicit state machines/invariants, idempotency/reconciliation, and making invalid states hard to represent. Doing this from the beginning also improves the developer experience: safer refactors, clearer reviews, fewer ‘tribal knowledge’ bugs.
For API evolution, T | null can be a pragmatic “relax/strengthen contract” knob with less mechanical churn than Option<T> (because many call sites don’t care and just pass values through). That said, it also makes it easier to accidentally reintroduce nullability and harder to enforce handling consistently, the failure mode is “it compiles, but someone forgot the check.”
In practice, once the union has more than “nullable vs present”, people converge to discriminated unions ({ kind: "ok", ... } | { kind: "err", ... }) because the explicit tag buys exhaustiveness and avoids ambiguous narrowing. So I’d frame untagged unions as great for very narrow cases (nullability / simple widening), and tagged/discriminated unions as the reliability default for domain states.
For reliability, I’d rather pay the mechanical churn of Option<T> during API evolution than pay the ongoing risk tax of “nullable everywhere.
My post argues for paying costs that are one-time and compiler-enforced (refactors) vs costs that are ongoing and human-enforced (remembering null checks).
Part of why I like “make invalid states unrepresentable” approaches is exactly that: it’s one of the few reliability investments that can pay back during feature work (safer refactors, fewer regressions), not only during incidents.
That’s exactly why I argue for stronger internal modeling: when the boundary is dirty, explicit state machines/ADTs + exhaustiveness + idempotency/reconciliation help ensure bad feeds don’t silently create invalid internal states.
Rust is actually aligned with the point: it delivers major reliability wins via making invalid states harder to represent (enums, ownership/borrowing, pattern matching). That’s not “FP-first,” but it’s very compatible with functional style and the same invariants story.
If the TS example came off as “types instead of validation,” that’s on me to phrase better, the point wasn’t “types eliminate validation,” it’s “types make the shape explicit so validation becomes harder to forget and easier to review.”
Tagged unions/ADTs make the discriminant explicit, which is exactly why they tend to be reliability-friendly: exhaustive matches + explicit constructors reduce “guessing” and refactor breakage.
That said, I agree the ergonomics matter, TS-style discriminated unions are basically “tagged” too once you add a kind field, for example.
Pure functions/immutability help a lot because tests become representative and cheap. I’d only push back on “tests of all equivalent scenarios” being sufficient, the space explodes and many real failures live at I/O/concurrency/distributed boundaries. My intended claim is that FP/ADTs/types reduce the state space and improve the ROI of tests, not replace them.
My angle was narrower: static types + ADTs improve the engineering loop (refactors, code review, test construction) by turning whole classes of mistakes into compiler errors. That’s not “what FP is”, it’s one very effective reliability layer that many FP ecosystems emphasize.
My point is narrower: those mechanisms still benefit from making illegal transitions unrepresentable (e.g. explicit state machines) so your retries/idempotency don’t create new failure modes. It’s not correctness vs tolerance, it’s correctness inside tolerant designs.
In hindsight I should have positioned types/ADTs as one layer in the reliability toolbox, not the toolbox.