skip to content

A build cache server exports `type Cache[K comparable, V any]`; a new teammate's `var c Cache` will not compile. Why, and how would you shape the API?

level: seniorimportance: should knowfreq 45%

answer

  1. a recipe, not yet a dish
  2. two blanks still unfilled
  3. every type position needs them
  4. one exported spelling for the common case
  5. the equals sign is load-bearing

basics

~20 s

A generic type name is not a type until it is instantiated, so every use needs type arguments. Export a constructor and an alias for the instantiation you support, and pin it with a compile-time check.

solid answer

~50 s

`Cache` on its own is a type *constructor*, not a type, so the compiler rejects `var c Cache` with "cannot use generic type ... without instantiation". Every place the name appears as a type — a variable, a struct field, a parameter, a map value, a channel element — must supply type arguments: `var c *Cache[string, []byte]`. That is correct but verbose, and it leaks into every signature a caller writes, which is what makes an onboarding engineer bounce off the package. The fixes are ergonomic, not semantic: export a **constructor** (`func NewCache[K comparable, V any](capacity int) *Cache[K, V]`), and export an **alias** for the instantiation your service actually uses — `type BlobCache = Cache[string, []byte]`. An alias, not a defined type: `type BlobCache Cache[string, []byte]` creates a new type with none of the methods. Then keep the instantiations honest with a package-level `var _ = NewCache[string, []byte]` in `example_test.go`, so a breaking change to the constraints fails the build.

code

go · 14 lines
go
type Cache[K comparable, V any] struct {
	entries map[K]*entry[V]
}

func NewCache[K comparable, V any](capacity int) *Cache[K, V] {
	return &Cache[K, V]{entries: make(map[K]*entry[V], capacity)}
}

// One spelling for the instantiation this server actually uses.
// The '=' matters: a defined type would carry none of the methods.
type BlobCache = Cache[string, []byte]

// In example_test.go - fails the build if the constraints or signature change.
var _ = NewCache[string, []byte]

go deeper

for a junior

Recall the fix: a generic type name needs its type arguments, so write *Cache[string, []byte] or call the package's constructor rather than declaring the bare name.

for a middle

Explain that the name is a type constructor and that every type position needs arguments, and know the alias-versus-defined-type distinction well enough not to lose the method set.

for a senior

An interviewer wants the review you would give: fix the declaration, add a constructor and an alias for the dominant instantiation, pin instantiations with a compile-time check, and challenge whether both parameters earn their place.

for a principal

Own the exported surface. Decide which instantiations your package promises to support, how that promise is enforced in CI, and what a change to a public constraint costs the teams that import you.

## Why the compile error happens A generic type declaration introduces a name that must be given type arguments before it denotes a type: ```go type Cache[K comparable, V any] struct { /* ... */ } var c Cache // error: cannot use generic type Cache without instantiation var c *Cache[string, []byte] // fine ``` Think of `Cache` as a recipe with two blanks. Until both blanks are filled the compiler does not know the field layout, the size, the method signatures or the zero value, so there is nothing to declare a variable of. Unlike a *call* to a generic function, where the argument types are usually enough to work the type arguments out, a type used in a declaration has no arguments to look at — there is nothing to infer from, so the arguments are always written. The rule applies at **every** occurrence of the name in type position: ```go type Server struct { blobs *Cache[string, []byte] // struct field } func handle(c *Cache[string, []byte]) {} // parameter var caches map[string]*Cache[string, []byte] // map value ch := make(chan *Cache[string, []byte], 1) // channel element ``` That repetition is the real complaint behind the compile error. The teammate's mistake is trivial to fix; the reason they made it is that the package made a five-word type name unavoidable in every signature. ## Shape one: a constructor Export a function, because functions can be written once and give callers somewhere to put defaults and validation: ```go func NewCache[K comparable, V any](capacity int) *Cache[K, V] { return &Cache[K, V]{entries: make(map[K]*entry[V], capacity)} } ``` This one still has to be called as `NewCache[string, []byte](128)`, because nothing in its argument list mentions `K` or `V`. A constructor is not a magic trick that removes type arguments; it removes them from every place *except* the point of construction, which is usually one line per program. ## Shape two: an alias for the instantiation you actually support If the build cache server really only ever stores `[]byte` under a `string` key, say so once: ```go type BlobCache = Cache[string, []byte] ``` The `=` is load-bearing. An **alias** is another spelling of the same type: `BlobCache` and `Cache[string, []byte]` are identical, interchangeable in every direction, and `BlobCache` has all of `Cache`'s methods. Dropping the `=` gives a **defined type**: ```go type BlobCache Cache[string, []byte] // different type, and no methods ``` which copies the underlying struct but inherits none of the method set, needs explicit conversions, and will confuse the next reader far more than the bracket list did. This is the single most common mistake made while trying to simplify a generic API. Since Go 1.24 an alias may itself take type parameters — `type Set[T comparable] = map[T]struct{}` — which is occasionally useful for naming a shape rather than an instantiation. Before 1.24 an alias could only name a fully instantiated type. ## Shape three: decide whether the parameters earn their place The honest senior answer includes a step back. Two type parameters on an exported type are a permanent tax on every caller's signatures. Ask what varies in practice: if the server only ever caches `[]byte` under `string` keys, a non-generic `Cache` with those types written in is smaller, faster to read and impossible to misuse. Keep the parameters when you genuinely have multiple instantiations in the same binary, when the type is a library other teams import with their own value types, or when the alternative is copy-pasting the container. Remove one when it is always the same type at every call site. ## Pinning the instantiations you promise Instantiation errors are **compile** errors, so a build that never instantiates `Cache[string, []byte]` will never tell you that you broke it. A package-level declaration in a test file fixes that cheaply: ```go // example_test.go var _ = NewCache[string, []byte] // instantiated function value; compile-time check ``` The blank identifier means nothing is exported and nothing runs; the declaration exists purely so that the test package fails to build if the constraint on `K` or `V` changes, if the constructor's signature changes, or if the type gains a third parameter. Pair it with a real `Example` function when you also want the usage documented on the package page. This is the cheapest available guard against a change that compiles for you and breaks every consumer. ## What to say in the review 1. `var c Cache` is not a bug in the language; a generic type name is not a type. 2. Write `*Cache[string, []byte]`, or better, call the constructor. 3. If one instantiation dominates, export an alias for it — with `=`, never without. 4. Pin the instantiations you support with a compile-time check in a test file. 5. Ask whether the second type parameter is carrying its weight at all.

  • Why prefer `type BlobCache = Cache[string, []byte]` over the same line without the equals sign?
    With `=` it is an alias: the very same type, fully interchangeable, carrying every method declared on `Cache`. Without `=` it is a new defined type whose underlying struct matches but whose method set is empty, so callers need conversions and lose `Put` and `Get`. The alias is what people mean; the defined type is a trap.
  • How do you stop a change to the constraints from silently breaking consumers?
    Instantiation is checked at compile time, so add a package-level `var _ = NewCache[string, []byte]` in a test file, plus an `Example` if you want it documented. The test package then fails to build the moment a constraint, a parameter count or a signature changes, instead of the breakage surfacing in someone else's repository.
  • When would you drop the type parameters from this exported cache entirely?
    When the binary only ever instantiates it one way. Two parameters on an exported type cost every caller a bracket list in every signature, and buy nothing if `K` is always `string` and `V` always `[]byte`. Keep them for a library other teams import with their own value types, or where several instantiations genuinely coexist.

saying these in an interview costs you the question

  • Says Go should infer the type arguments for a var declaration
  • Uses a defined type instead of an alias and loses the methods
  • Thinks a constructor removes type arguments from the construction site
  • Believes an unused instantiation is checked without being written anywhere
  • Never questions whether both type parameters are needed