Skip to content

Instantly share code, notes, and snippets.

@arturaz
Created August 25, 2026 20:20
Show Gist options
  • Select an option

  • Save arturaz/39fc5a1338c7c190d25ad3e9dfa80815 to your computer and use it in GitHub Desktop.

Select an option

Save arturaz/39fc5a1338c7c190d25ad3e9dfa80815 to your computer and use it in GitHub Desktop.

Here's a single worked example you can paste. One domain, both languages, and an experiment he can run himself in ten minutes.


The domain

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.

Go, attempt 1: the struct everyone actually writes

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, attempt 2: the version a good Go engineer writes

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.

Scala

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.

The experiment

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.

The honest other side

  • 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 //nolint escape 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment