Sealed Types in Golang

Kotlin’s favorite feature has been in Go this whole time

Gavin Killough
CodeX

--

Kotlin icon with an arrow pointing to Go icon

Over the past six months, I spent a large portion of my energy on project written in Kotlin. My Java background gave me immediate familiarity with much of the language, but one feature that intrigued me was the ability to “seal” a type.

What is a Sealed Type?

According to Kotlin’s documentation:

Sealed classes and interfaces represent restricted class hierarchies that provide more control over inheritance. All direct subclasses of a sealed class are known at compile time. No other subclasses may appear outside the module and package within which the sealed class is defined.

At first, I was skeptical. Doesn’t a class or interface knowing about its implementations defeat the whole purpose of polymorphism? Interfaces and abstract classes let a consumer perform consistent operations without needing to know the implementation details. In Java, if a method returns a List interface, the consumer can benefit from order, iteration, and index-based access without needing to know whether it’s backed by an ArrayList, a LinkedList, or any other List implementation. Sealed types appear to be in direct violation of this pattern.

While I have yet to hear a great argument for sealed types as an Object Oriented pattern, one of my colleagues convinced me of their value by looking at them through a different paradigm: Functional Programming.

Sealed types are a Functional Programming pattern masquerading as an Object Oriented one. This becomes apparent with Kotlin’s when expression. To illustrate this, let’s look at some code.

Sealed Interface — Kotlin

data class TodoPage(val items: List<TodoItem>)

data class TodoItem(val description: String, val dateAdded: String, val dateCompleted: String)

sealed interface TodosPageResponse {
data class Success(val page: TodoPage) : TodosPageResponse
object InvalidPageSize : TodosPageResponse
object InvalidPageNumber : TodosPageResponse
object NotFound : TodosPageResponse
}

fun fetchTodosPaginated(listId: String, size: Int, pageNo: Int, query: String) : TodosPageResponse {
if (size < 0) {
return TodosPageResponse.InvalidPageSize
}
if (pageNo < 1) {
return TodosPageResponse.InvalidPageNumber
}
// etc.
// . . .
return TodosPageResponse.Success(TodoPage(...))
}

The method fetchTodosPaginated returns a sealed interface, TodosPageResponse. This expresses all of the possible ways the method could safely exit (Kotlin has unchecked/runtime exceptions, so an unsafe exit could still occur). This syntax urges the consumer to handle every possible exit state. Kotlin even provides smart casting for each conditional block, and can perform a compile-time check that all possibilities are exhausted.

when (val result = fetchTodosPaginated(...)) {
// notice the result variable is actually being "smart cast" to TodosPageResponse.Success
// allowing us to access `resut.page`
is TodosPageResponse.Success -> println("Success! ${result.page.items.size} items found.")
is TodosPageResponse.InvalidPageSize -> println("Invalid page size")
is TodosPageResponse.InvalidPageNumber -> println("Invalid page number")
is TodosPageResponse.NotFound -> println("No results found")
}

Of course, the consumer of fetchTodosPaginated could still do a traditional instance check for TodosPageResponse.Success and disregard other possibilities. It could also use an else clause to do a non-exhaustive check, depending on the use-case, but when gives us such expressive syntax for handling each possibility, that it’s clearly the preferred choice.

So how is this functional? Notice the right-hand-side of each case in the when expression. There’s some syntactic sugar baked in, but those are actually anonymous functions¹! What the sealed type has essentially created is an inverted callback, where the consumer needs to provide a function to handle each exit path. Functional purists will balk at this assessment, but this is how modern hybrid languages provide the best of OOP and FP together.

I won’t break down all the details of how Kotlin’s sealed types work, there are plenty of articles for that, but this should give us a reference point to talk about them in Go.

Why do we want this in Go?

Many Go fans believe the language designers got errors right. The errors package from the standard library already has plenty of tools for creating, wrapping, unwrapping, and generally managing errors — not to mention error is an interface, so you can always extend it with more complex types. I still believe sealed types can play an important role in offering more expressive code in libraries and domain functions for three reasons.

Failure != Error

The distinction between failures and errors is one that I believe is too often ignored.

A failure is a termination of an operation due to a rule or precondition not being met; a failure indicates the system is healthy, but the operation has been rejected. Failures can result from violations of domain invariants, invalid inputs, or data readiness checks.

On the other hand, an error indicates a problem with the system. These can be connectivity issues, or other runtime situations that the caller has no way to remedy. They aren’t fatal, and may resolve eventually, but they offer no guarantees about how and when they will be resolved. Errors are the kinds of things 5xx codes are used for in an HTTP response.

For completeness, there are also the fatal kind of errors. Go’s concept for these is called panic. To quote Dave Cheney, “when you panic in Go, you’re freaking out, it’s not someone else’s problem, it’s game over man.” These are the kind of system-level issues that should immediately end the execution of the program such as segmentation faults or deadlocks.

I draw these distinctions fully aware that most developers and even most programming languages usually disregard them. The error type is often used for failures, resulting in ugly code that leaks implementation details with excessive type checks and casting. These are sometimes referred to as “sentinel errors” and Go has a few of them baked in, including io.EOF and sql.ErrNoRows. These however are exceptions to the rule, and — dare I say — a mistake in the Go standard library. Errors are meant to be opaque; as soon as you open that black-box, you’re inviting in all kinds of bad practices and inappropriate coupling.

Sealed types are the missing link in separating failures from errors. With a sealed type, you can explicitly express to the consumer the different reasons a function might exit without needing hefty documentation or an unbounded number of type checks on an error.

Sealed types are better enums

Go doesn’t have enums. The debate continues to rage as to whether or not the language needs them, but there are currently numerous proposals requesting first-class language support for enums (#1, #2, #3, #4, #5, #6, …).

Currently, the best Go has to offer is constants — usually integer-derived types — initialized with iota, let’s call these “pseudo-enums”. This is nothing more than syntactic sugar and is still a far cry from real enums. The problem is, new instances of a pseudo-enum can still be created, inside or outside a package. Demanding them as parameters gives consumers a hint as to what you want, but untyped literals can still be used, meaning forbidden values are not a compilation error. There are really no protections here.

Now at least one major reason enums are criticized is because using them as input is often an indication of poor abstraction. Using them as output, however, can actually be a perfectly reasonable way to express a finite set of results from a function. Sealed types offer the same benefit as enums, but with the added benefit of providing additional context and structure in each variation.

For example, the HTTP status code 403 might indicate a user lacks a certain permission. Returning just an integer doesn’t tell the user what permission they’re missing, but an instance of a sealed type is structured data and could include a slice with the missing permission. If we only used an integer as our result, we would not be expressing the full scope of the problem, but 403 is the only status that has use for a “missing permissions” slice. We don’t want to bloat other statuses with this irrelevant metadata, and sealed types offer an elegant solution to this problem.

People have been asking for them

Don’t just take my word for it. Detailed proposals for this feature have already been submitted for future versions of Go:

Ok, just because people are asking doesn’t mean sealed types belong in Go. That being said, it is a good indicator that many in the community feel there is value to be gained by these or similar features. Furthermore, many of the Go maintainers have taken the above proposals seriously², but simply take a very conservative approach to introducing any new language features — especially ones this significant.

So how do we get there in Go?

Although some might want sealed types as a new language feature, we actually already have a path forward.

Let’s start with some simplified code that one might come across in a Todo application. I’ve tried to make it analogous to the Kotlin example above.

type TodoPage struct {
Items []TodoItem `json:"items"`
// page metadata, etc.
}

type TodoItem struct {
Desc string `json:"description"`
DateAdded string `json:"dateAdded"`
DateCompleted string `json:"dateCompleted"`
}

type TodosPageRequest struct {
ListId string `json:"listId"`
Size int64 `json:"pageSize"`
PageNo int64 `json:"pageNumber"`
Query string `json:"q"`
}

func FetchTodosPaginated(r TodosPageRequest) (*TodoPage, error) {
if r.Size < 1 {
return nil, errors.New("invalid page size")
}

if r.PageNo < 1 {
return nil, errors.New("invalid page number")
}

if exists, err := listExists(r.ListId); err != nil {
return nil, err
} else if !exists {
return nil, fmt.Errorf("todo list not found for list id: %s", r.ListId)
}

return executeQuery(r)
}

This code is already following Go idioms for error handling, but not all the errors are errors. A closer look shows that we actually have a mix of failures and errors, but we’re treating them all the same way.

So what might we do? Well, we could introduce an additional return value. This could represent the failures, the error can still represent the errors, and our success case remains the first return value:

func FetchTodosPaginated(r TodosPageRequest) (*TodoPage, string, error) {
if r.Size < 1 {
return nil, "invalid page size", nil
}

if r.PageNo < 1 {
return nil, "invalid page number", nil
}

if exists, err := listExists(r.ListId); err != nil {
return nil, "", err
} else if !exists {
return nil, fmt.Sprintf("todo list not found for list id: %s", r.ListId), nil
}

page, err := executeQuery(r)
if err != nil {
return nil, "", err
}
return page, "", nil
}

It’s not obvious what that string represents, so we could name it, or document it, but now our intent is a bit more convoluted.

Another option is to use a status code:

type FetchStatus int8

const (
Error FetchStatus = iota
InvalidPageSize
InvalidPageNo
NotFound
Success
)

func FetchTodosPaginated(r TodosPageRequest) (*TodoPage, FetchStatus, error) {
if r.Size < 1 {
return nil, InvalidPageSize, nil
}

if r.PageNo < 1 {
return nil, InvalidPageNo, nil
}

if exists, err := listExists(r.ListId); err != nil {
return nil, Error, err
} else if !exists {
return nil, NotFound, nil
}

page, err := executeQuery(r)
if err != nil {
return nil, Error, err
}
return page, Success, nil
}

We’ve essentially created an enum now. Some might prefer this to the string, though by adding a few string constants in the previous example, or a function providing a “user friendly message”, we will largely accomplish the same thing as before. Neither of these approaches is quite the same as a sealed type though.

So what’s missing?

Thanks to recognizing this as a functional pattern, and Go’s treatment of functions as types, we can achieve this through functions…lots of them.

The trick is to return a function that accepts several functions: one for each result case. We can then choose which function to call depending on how our function exits. If it doesn’t make sense yet, just bear with me; it will all be clear by the end.

func FetchTodosPaginated(r TodosPageRequest) (
func(
handleSuccess func(*TodoPage),
handleInvalidPageSize func(),
handleInvalidPageNo func(),
handleNotFound func(),
),
error,
) {
if r.Size < 1 {
return func(
_ func(*TodoPage),
handleInvalidPageSize func(),
_ func(),
_ func(),
) {
handleInvalidPageSize()
}, nil
}

if r.PageNo < 1 {
return func(
_ func(*TodoPage),
_ func(),
handleInvalidPageNo func(),
_ func(),
) {
handleInvalidPageNo()
}, nil
}

if exists, err := listExists(r.ListId); err != nil {
return nil, err
} else if !exists {
return func(
_ func(*TodoPage),
_ func(),
_ func(),
handleNotFound func(),
) {
handleNotFound()
}, nil
}

page, err := executeQuery(r)
if err != nil {
return nil, err
}
return func(
handleSuccess func(*TodoPage),
_ func(),
_ func(),
_ func(),
) {
handleSuccess(page)
}, nil
}

That code is hardly as elegant as Kotlin’s. Few Go developers will be able to decipher it on the first pass, even with well-formatted wrapping. Luckily, by introducing a few types, we can make it a bit more readable.

type SuccessHandler func(*TodoPage)
type InvalidPageSize func()
type InvalidPageNo func()
type NotFound func()

type TodosPageResp = func(
handleSuccess SuccessHandler,
handleInvalidPageSize InvalidPageSize,
handleInvalidPageNo InvalidPageNo,
handleNotFound NotFound,
)

There, that’s a little better. We could also use interfaces, although that would take up more screen real estate. Optionally, we could allow each type to return an error, giving consumers even more flexibility when they handle each case. I’ve left errors out for now to keep the example simple.

With those types, we’re getting close, but we can also give ourselves a few helpers to make things more readable in the domain function:

func handleSuccess(page *TodoPage) TodosPageResp {
return func(handle SuccessHandler, _ InvalidPageSize, _ InvalidPageNo, _ NotFound) {
handle(page)
}
}

var handleInvalidPageSize = func(_ SuccessHandler, handle InvalidPageSize, _ InvalidPageNo, _ NotFound) {
handle()
}

var handleInvalidPageNo = func(_ SuccessHandler, _ InvalidPageSize, handle InvalidPageNo, _ NotFound) {
handle()
}

var handleNotFound = func(_ SuccessHandler, _ InvalidPageSize, _ InvalidPageNo, handle NotFound) {
handle()
}

Bringing it all together, now our domain function looks a lot like the Kotlin example:

func FetchTodosPaginated(r TodosPageRequest) (TodosPageResp, error) {
if r.Size < 1 {
return handleInvalidPageSize, nil
}

if r.PageNo < 1 {
return handleInvalidPageNo, nil
}

if exists, err := listExists(r.ListId); err != nil {
return nil, err
} else if !exists {
return handleNotFound, nil
}

page, err := executeQuery(r)
if err != nil {
return nil, err
}
return handleSuccess(page), nil
}

Now when we call the function from elsewhere in the application, the function it returns — an instance of TodosPageResp—must be passed functions to handle each possible exit path.

 handleResult, err := FetchTodosPaginated(req)
if err != nil {
return
}

handleResult(
func(page *TodoPage) {
// If the result was success, I can now use the page...
for _, item := range page.Items {
fmt.Println(item)
}
},
func() {
fmt.Println("invalid page size")
},
func() {
fmt.Println("invalid page number")
},
func() {
fmt.Println("not found")
},
)

This looks a lot like Kotlin’s when expression, the only difference is Go’s lambdas are a bit more verbose, and Go doesn’t provide the syntactic sugar that Kotlin does (i.e. one-liners not needing to be inside of curly braces).

For simplicity, I’ve decided to just print something for each case, but imagine calling this from an HTTP Handler function. Each of these return paths could write a status code and body depending on the failure condition (e.g. 400 vs 404). No more type casting or string comparisons on err.Error() to see if your database returned a sql.ErrNoRows — your handlers shouldn’t have ever cared about that, and now they don’t have to. Instead of tempting the consumer to unwrap an error and guess its type, you’ve enforced the handling of all possibilities.

When should you use this?

Every tool has its time and place, but I find this pattern to be incredibly useful in libraries and domain functions. It prevents consumers from coupling themselves to errors returned by implementation details (like a database or third party SDK), and draws a distinction between errors and failures. It’s rather heavy-handed in Go, but Go makes such things this way by design. If it feels painful or tedious in your use-case, it might not be the right choice — then again, people say the same thing about loops and if err != nil so is it tedious, or is it just Go?

If you were looking for sealed types in Go, I hope this gave you what you were looking for. If you weren’t, I hope this gave you something to think about. This pattern isn’t for every use case, and it may not fit your preferred style; that’s ok! This is one more tool in the toolbox, and Go provided that toolbox for precisely cases like this. The Go maintainers are very conservative about introducing new language features, so don’t expect Kotlin-style syntax any time soon. For now, we can lean on Go’s type system to do the heavy lifting and lean on our developer intuition to know when and where to use such patterns.

Footnotes:

¹ Ok, these are technically closures, but the distinction wasn’t important to the point.

² See the discussions in the proposals for more details.

--

--

Gavin Killough
CodeX
Writer for

Gavin is a software developer who is always looking for ways to improve code quality and build expressive APIs.