Go Module 6 – Interfaces and type assertions
Enums and sum types: Go patterns that work
Model closed sets in Go with named types, constants and validation, emulate sum types with sealed interfaces and type switches, and catch missing cases.
What you will learn
- Model a closed set of values with a named type, its constants and a validity check
- Build a sum type from a sealed interface and a type switch whose default returns an error
- Validate values at the boundary with MarshalText and UnmarshalText
- Write a state machine that refuses every move it does not list
- Catch missing cases with a check over every variant and with an exhaustiveness linter
Before you start
What you’ll learn · Before you start
What you will learn
- Model a closed set of values with a named type, its constants and a validity check
- Build a sum type from a sealed interface and a type switch whose default returns an error
- Validate values at the boundary with MarshalText and UnmarshalText
- Write a state machine that refuses every move it does not list
- Catch missing cases with a check over every variant and with an exhaustiveness linter
Before you start
On this page
In the 2025 Go Developer Survey, 65 % of respondents named a next-favourite language that has type-safe enums, and enums and sum types came up again among the features they missed. Go has neither keyword, yet Go code models closed sets every day, a payment’s status or the outcome of a payment attempt, with tools you already know.
You will build enums from a named type and its constants, see what that type does not stop, and emulate sum types with a sealed interface and a type switch. What each program printed on Go 1.27.1 is shown right below it.
A named type and its constants make an enum, but not a closed one
An enumeration: A fixed set of named values, such as the states of an order. In Go it is a named type, usually an integer type, with one constant for each value, often numbered by iota. More in “Constants, untyped values and iota” in Go is a named type with one constant per value. Go enums are often integers numbered by iota, as Constants and iota shows, and often strings, which read well in JSON and in logs. Here is a payment status as a string type, with a check for the five valid values:
// status.go: a payment status as a named string type, and what the type alone does not stop.
package main
import (
"encoding/json"
"fmt"
"slices"
)
// Status is the state of a payment.
type Status string
const (
Created Status = "created"
Pending Status = "pending"
Success Status = "success"
Failed Status = "failed"
Refunded Status = "refunded"
)
var allStatuses = []Status{Created, Pending, Success, Failed, Refunded}
// IsValid reports whether s is one of the five statuses.
func (s Status) IsValid() bool { return slices.Contains(allStatuses, s) }
type Payment struct {
ID string `json:"id"`
Status Status `json:"status"`
}
func main() {
fmt.Println(Success, Success.IsValid())
odd := Status("settled") // any string converts to Status
fmt.Println(odd, odd.IsValid())
var p Payment
err := json.Unmarshal([]byte(`{"id": "TXN1003", "status": "REFUND_PARTIAL"}`), &p)
fmt.Println(p.Status, p.Status.IsValid(), err)
fmt.Println(p.Status == "sucess") // a misspelt constant compiles and is simply false
} Output
success true settled false REFUND_PARTIAL false <nil> false
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run status.go
Success is valid, but the type stopped nothing else. Status("settled") compiled, because any string converts to
Status. json.Unmarshal stored REFUND_PARTIAL without an error, because Status is a string type to json. And
p.Status == "sucess" compiled and was false, because an untyped string constant converts to Status as well, typo
and all. The check exists, but nothing calls it unless your code does.
The two common shapes differ in what goes wrong:
| An int type with iota | A string type | |
|---|---|---|
| stored and sent as | a number, such as 2 | its name, such as success |
| readable in logs and JSON | only with a String method | as it is |
| reordering the constants | gives stored numbers new meanings | changes nothing |
| the zero value | the first constant, often a real state | the empty string, never a valid name |
| any value converts to it | any int | any string |
Key idea
Go enums are named types with constants: they document the set, but only your own checks keep other values out.
Checkpoint
Check: Enums from named types and constants
A sealed interface is Go’s sum type
A sum type: A type whose values are each exactly one of a fixed set of kinds, each kind with its own data, such as Paid, Declined or Pending. Go has no sum types; a sealed interface and a type switch emulate them. says that a value is exactly one of a few kinds, each with its own data: a payment attempt ended Paid with a reference, Declined with a code, or Pending with a time to retry. The Go FAQ explains that variant types were left out because they overlap with interfaces in confusing ways, and that an interface value with a type switch already covers much of what they would do. Two details close the set: an unexported method, which makes the interface sealed interface: An interface with an unexported method, such as isResult(). Only the interface's own package can write that method, so no other package can declare a new type that has it., and a default case that refuses what it does not know:
cmd/demo/main.go · payments/cmd/demo/main.go
// The demo: one message for each of the three results.
package main
import (
"fmt"
"time"
"example.com/payments/result"
)
func main() {
for _, r := range []result.Result{
result.Paid{Ref: "UTR0412", Paise: 49900},
result.Declined{Code: "51"},
result.Pending{RetryAfter: 30 * time.Second},
} {
msg, err := result.Describe(r)
fmt.Printf("%-17T %s %v\n", r, msg, err)
}
} Output
result.Paid paid 499.00 rupees, reference UTR0412 <nil> result.Declined declined by the bank, code 51 <nil> result.Pending pending, checking again in 30s <nil>
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run main.go
result/result.go · payments/result/result.go
// Package result describes how a payment attempt ended: exactly one of Paid, Declined or Pending.
package result
import (
"fmt"
"time"
)
// Result is a sealed interface: its method isResult is unexported, so no other package can declare it.
type Result interface{ isResult() }
// Paid carries the bank's reference and the amount in paise.
type Paid struct {
Ref string
Paise int64
}
// Declined carries the bank's reason code.
type Declined struct{ Code string }
// Pending says when to ask again.
type Pending struct{ RetryAfter time.Duration }
func (Paid) isResult() {}
func (Declined) isResult() {}
func (Pending) isResult() {}
// Describe writes the message a customer sees. Its default case returns an error, never a guess.
func Describe(r Result) (string, error) {
switch v := r.(type) {
case Paid:
return fmt.Sprintf("paid %d.%02d rupees, reference %s", v.Paise/100, v.Paise%100, v.Ref), nil
case Declined:
return "declined by the bank, code " + v.Code, nil
case Pending:
return fmt.Sprintf("pending, checking again in %v", v.RetryAfter), nil
default:
return "", fmt.Errorf("result: no message for %T", r)
}
} Each variant carries its own fields, and Describe’s type switch gave each its message. A type in another package cannot declare isResult for itself, because the method has an unexported name:
// outside: another package tries to add a fourth result.
package main
import "example.com/payments/result"
// Refund wants to be a Result, with an isResult method of its own.
type Refund struct{}
func (Refund) isResult() {}
var _ result.Result = Refund{}
func main() {} Output (exit status 1)
Printed as an error (standard error)
# command-line-arguments
./main.go:11:23: cannot use Refund{} (value of struct type Refund) as result.Result value in variable declaration: Refund does not implement result.Result (unexported method isResult)
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run main.go
Refund does not implement result.Result (unexported method isResult): an unexported method name belongs to its own
package, so the isResult that main declares is a different method. Step through who can be a Result:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- An unexported method. Result lists one method, isResult, whose name starts with a small letter. Only code in package result can declare a method by that name that counts.
- Three variants. Paid, Declined and Pending each declare isResult with an empty body. That is all it takes, and each carries its own fields.
- One switch covers them. Describe switches on the dynamic type, with one case per variant and a default that returns an error instead of a guess.
- Another package tries. Package main declares Refund with an isResult method of its own. An unexported name belongs to its package, so it is a different method.
- Refused. The compiler says Refund does not implement result.Result: unexported method isResult. Only package result can declare that method, so the variants it declares are the ones its switches list.
These patterns go further in two directions. An enum can refuse unknown values itself, through MarshalText and UnmarshalText, and a state machine can refuse every move it does not list. And a missing case can be caught before production, by a check over every variant and by exhaustiveness linters.
Checkpoint
Check: Sealed interfaces as sum types
Enums that refuse bad values, and a state machine that names every move Pro: opens with a Pro, Premium or Ultimate pass
An enum that only documents its values lets bad ones in at every boundary. This part gives Status the two text methods that json and flag call, deals with the status that never arrived, and builds a state machine whose table of allowed moves refuses everything else.
In this part:
- UnmarshalText turns an unknown status into an error
- A missing field is the zero value, so validate after decoding
- A state machine with a table of allowed moves
2 runnable examples (Go) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Catching the missing case before production Pro: opens with a Pro, Premium or Ultimate pass
Go reports no missing case in a switch, so the day a variant is added, every switch over the set needs a human to remember it. This part runs every variant through every switch, opens the seal from outside to show why the default matters, and sets up linters that know the closed set.
In this part:
- A check that runs every variant through every switch
- Embedding opens the seal from outside
- Linters that know the closed set
2 runnable examples (Go) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
Practice asks about enums and sealed interfaces, starting with whichever your answers show is weaker:
Practice that adapts to you
Practise this lesson
4 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): How do you write an enum in Go?
Declare a named type, usually on int or string, and one constant per value: type Status string with
const Success Status = "success", or an int type with iota. Add a method that checks validity, and a String method
for an int type. The type documents the set, but any value of its underlying type converts to it, so check values that
come from outside.
Three longer questions of the module bank build on this lesson:
-
Fresher to mid
A partner's webhook sends payment statuses as strings, and one day a new value, CHARGEBACK, arrives. Your service decoded it into its string-typed Status without an error, and a daily report counted those payments as successful. Explain how, and design the type so that unknown statuses are handled on purpose.
-
Mid to senior
Your package defines a sealed Event interface with an unexported marker method, and Apply switches over every variant with a default that panics. A team's test double in another package embeds Event, and a staging run panics inside Apply. Explain how the double got past the seal, and what you change.
-
Mid to senior
Payments move through created, pending, success, failed and refunded, and bugs keep appearing where a failed payment is refunded or a refunded one is confirmed. Design the transitions in Go so that an invalid move cannot go unnoticed, and say how you test them.
Key takeaways
- Go enums are named types with one constant per value; any value of the underlying type converts to them, so they document a set without closing it.
- String enums read well and survive reordering, while int enums with iota need a String method and must never be renumbered once stored.
- A sealed interface, with an unexported method, and a type switch whose default returns an error, emulate a sum type.
Terms in this lesson
- sealed interface
- An interface with an unexported method, such as isResult(). Only the interface's own package can write that method, so no other package can declare a new type that has it.
- sum type
- A type whose values are each exactly one of a fixed set of kinds, each kind with its own data, such as Paid, Declined or Pending. Go has no sum types; a sealed interface and a type switch emulate them.
References
- Go Developer Survey 2025 Results (The Go Authors)
- Frequently Asked Questions: Why does Go not have variant types? (The Go Authors)
- The Go Programming Language Specification: Iota (The Go Authors)
- The Go Programming Language Specification: Exported identifiers (The Go Authors)
- The Go Programming Language Specification: Type switches (The Go Authors)
- Package encoding: TextMarshaler and TextUnmarshaler (The Go Authors)
- PostgreSQL Documentation: Check Constraints (The PostgreSQL Global Development Group)
- golangci-lint: linter settings (exhaustive, gochecksumtype) (golangci-lint)
- golangci-lint: linters (exhaustive, gochecksumtype) (golangci-lint)
- exhaustive: checks exhaustiveness of enum switch statements (nishanths)
- go-check-sumtype: exhaustiveness checks for sum types (alecthomas)
Report a problem with this lesson
Kept only in this browser. Your Learn progress