Here's a single worked example you can paste. One domain, both languages, and an experiment he can run himself in ten minutes.
A payment attempt has exactly three outcomes:
- Succeeded — we have a transaction id.
- Declined — we have a reason code.
- Requires action — we have a redirect URL and a client secret. The secret must never be written to a log, a trace, or a database row.
type PaymentResult struct {
Status string // "succeeded" | "declined" | "requires_action"
TransactionID *string
DeclineReason *string
RedirectURL *string
ClientSecret *string
}Count the states this type admits: Status is a string, so infinitely many, but even pretending it's three, four nilable fields give 3 × 2⁴ = 48 combinations. Three are legal. The other 45 compile. Status: "succeeded" with a nil TransactionID compiles. Status: "declined" carrying a ClientSecret compiles.
The consumer:
func describe(r PaymentResult) string {
switch r.Status {
case "succeeded":
return "ok: " + *r.TransactionID // panic if nil
case "declined":
return "declined: " + *r.DeclineReason
case "requires_action":
return "redirect to " + *r.RedirectURL
}
return "unknown"
}case "suceeded": — typo, compiles, falls to "unknown" in production. This is the part where the language decides what's cheap: modelling this correctly in Go is work, so most people don't, and it isn't laziness. It's the path of least resistance the language laid out.
Go can do better, and this should be said out loud rather than skipped:
type PaymentResult interface{ isPaymentResult() }
type Succeeded struct{ TransactionID string }
type Declined struct{ Reason DeclineReason }
type RequiresAction struct {
RedirectURL string
ClientSecret string
}
func (Succeeded) isPaymentResult() {}
func (Declined) isPaymentResult() {}
func (RequiresAction) isPaymentResult() {}The unexported marker method means no other package can add a variant. Illegal states are gone — 48 combinations down to 3. This is genuinely good, and it's most of the value.
Two things remain broken.
One. The consumer:
func describe(r PaymentResult) string {
switch v := r.(type) {
case Succeeded: return "ok: " + v.TransactionID
case Declined: return "declined: " + string(v.Reason)
case RequiresAction: return "redirect to " + v.RedirectURL
}
return "unknown" // ← the compiler *requires* this line
}That last line is not optional. Go demands a return, because it has no idea the switch is total. The compiler forces you to write the exact line that will silently swallow the variant you add next year. Every type switch in the codebase has one.
Two. Serialization:
b, _ := json.Marshal(result) // ClientSecret is an exported string. It goes in.encoding/json works by reflection over exported fields. Protection is opt-in (json:"-"), per field, per struct, and invisible if forgotten. In a nested tree — a result inside a trace inside an audit record — the leak is three levels down from the call you're reviewing.
opaque type TransactionId = String
opaque type RedirectUrl = String
final case class ClientSecret(value: String)
enum PaymentResult:
case Succeeded(transactionId: TransactionId)
case Declined(reason: DeclineReason)
case RequiresAction(redirectUrl: RedirectUrl, clientSecret: ClientSecret)Three cases, three shapes, no nilable fields, nothing to get wrong. Consumer:
def describe(r: PaymentResult): String = r match
case Succeeded(id) => s"ok: $id"
case Declined(reason) => s"declined: $reason"
case RequiresAction(url, _) => s"redirect to $url"No default branch. Not "we chose not to write one" — there is nothing left for it to catch, so writing one is a compile warning about unreachable code.
For the secret, the trick from my own codebase: guarantee by absence.
// Deliberately no `given Encoder[ClientSecret]` anywhere in the project.Now:
given Encoder[PaymentResult] = deriveEncoder
// ✗ could not find implicit value for parameter: Encoder[ClientSecret]The build fails. Not a linter, not a review comment, not a // DO NOT LOG — the encoder cannot be constructed because a piece of it does not exist. There is no json:"-" to forget, because there is no reflection-based fallback that would serialize it anyway. This is the same idea as Go's unexported field, except it composes through arbitrary nesting: a ClientSecret fifty levels down still makes the whole derivation fail.
This is the part worth actually doing, because it's empirical rather than ideological. Add a fourth outcome — Chargeback(reversedAt: Instant) — in a codebase with, say, forty places that consume a payment result.
Go: everything compiles. Ship it. Forty return "unknown" branches now silently absorb chargebacks, and you find out from a customer.
Scala: forty compile errors, each one a file and line number, each one saying which pattern is missing. The list is complete by construction — not "grep found forty, hopefully that's all."
That's the difference in one sentence: in one language adding a case is a search problem, in the other it's a checklist the compiler hands you.
- Go has
go-check-sumtype, and it covers the exhaustiveness gap fairly well. It's a linter — a CI step with a config file and//nolintescape hatches — but it's real, and it closes the biggest of the three holes. - The secret-leak gap is much harder to close with tooling, because the rule isn't "don't marshal X," it's "don't marshal anything that might contain X anywhere inside," which is whole-program dataflow through
any. - Scala's price is real: the derivation error above is one of the good ones. Implicit resolution failures can be genuinely awful, compile times are worse by an order of magnitude, and a codebase where everyone reaches for type-level tricks becomes unreadable to new hires. Go traded expressiveness for a language a 2000-person org can read uniformly, and that was a deliberate, defensible choice for Google's problem.
So the claim isn't "Go is bad." It's narrower and harder to wave away: the language decides which questions you're able to ask the compiler. Go lets you ask "do these types line up?" Scala lets you ask "have I handled everything?" and "can this value even be written out?" — and when an LLM is generating the code faster than you can read it, the second set of questions is the one that matters.