A real life use-case for generics in Go: API for client-side pagination
Let’s say we have a RESTful API for a general ledger, with the endpoints, that return a paginated collection of resources:
GET /accounts
, retrieves a list of accounts, filtered and sorted by some query parameters;GET /accounts/:uuid/transactions
, retrieves a list of transactions for account;GET /postings
, retrieves a list of postings stored in the ledger.
The actual structure and the response body are the implementation details, and aren’t important here.
With Go 1.18 we may spec out the methods for a API client for this API as corresponding ListSomething
methods, that return a generic type Pager[T any]
:
type LedgerAPIClient struct {
// unexported fields
}
func (*LedgerAPIClient) ListAccounts(···) *Pager[Account]
func (*LedgerAPIClient) ListAccountTransactions(···) *Pager[Transaction]
func (*LedgerAPIClient) ListPostings(···) *Pager[Posting]
The API of the generic type Pager[T any]
can look as following:
// PagerDone is a sentinel error, which pager's NextPage and PrevPage methods return,
// when there aren't any more pages to iterate over.
var PagerDone = errors.New("no more pages to iterate")
type Pager[T any] struct {
// unexported fields
}
// NextPage retrieves the next page in the collection of T.
// It returns PagerDone error after it reaches the last page.
func (*Pager[T]) NextPage() ([]T, error)
// PrevPage retrieves the previous page in the collection of T.
// It returns PagerDone error after it reaches the first page.
func (*Pager[T]) PrevPage() ([]T, error)
type PageInfo struct {
Token string
HasNext bool
HasPrev bool
}
// PageInfo returns a metadata about the current page.
func (*Pager[T]) PageInfo() PageInfo
Under the hood, the Pager[T]
type implements an iterator, that keeps the state with the current page, and calls the API endpoints, via a backref to its API client, to retrieve the list with the next or previous page. The actual implementation may look as shown in this example gist.
In order to traverse through the collection of the resources, the users of our LedgerAPIClient
call the List
method of the relevant API resource, and iterate through the returned pager, until the latter is exhausted, or the user is satisfied with the results at hands:
c := api.NewLedgerAPIClient()
pager := c.ListAccounts(ctx, input)
for {
accs, err := pager.NextPage()
if errors.Is(err, api.PagerDone) {
break
}
if err != nil { ··· }
for _, acc := range accs {
// do something with this page's list of accounts
}
}
Let’s say, for a certain use-case it’s more convenient to work with the collection as if it wasn’t a paginated list of resources, but as if it was a continuous stream of individual recourses.
What is nice about this implementation is that the generic type Pager[T]
implements a generic interface:
type pageIterator[T any] interface {
NextPage() ([]T, error)
}
We can implement a helper, that accepts a pageIterator[T any]
interface, and using its NextPage()
method, the helper scans through the whole collection, returning its items one by one. Generics allow implementing this helper once, and it will work with any type of resource in our API client, and provide a strongly typed API to a user to work with:
type PageScanner[T any] struct {
Pager pageIterator[T]
// a buffer of items yet to be conumed by the user
list []T
}
// Next returns next item from the underlying pager.
// After all items of the current page are consumed, PageScanner moves to the next page.
func (s *PageScanner[T any]) Next() (item T, err error) {
for len(s.list) == 0 {
s.list, err = s.Pager.NextPage()
if err != nil {
return item, err
}
}
item = s.list[0]
s.list = s.list[1:]
return item, nil
}
That’s how the user will apply that to their code:
c := api.NewLedgerAPIClient()
pager := c.ListAccountTransactions(ctx, input)
// (caveat) it seems that, in Go 1.18 the compiler can't figure out the underlying type
// of the pager's list, so we have to specify the type explicitely, when create PageScanner[T]
scanner := &api.PageScaner[Transaction]{
Pager: pager,
}
for {
trx, err := scanner.Next()
if errors.Is(err, api.PagerDone) {
break
}
if err != nil { ··· }
// do something with an individual transaction
}
What was your experience with the generics in Go so far? Let’s discuss this note on Hacker News and Reddit, or share your thoughts with me on Twitter.