skip to content

Why can't a Go `[]string` be spread into a `...any` parameter, and what does the workaround cost?

level: middleimportance: should knowfreq 44%

answer

  1. the spread is assignment, not conversion
  2. two slice types, one refused
  3. no covariance for composite types
  4. an interface value is not a string header
  5. you must build the []any yourself

basics

~20 s

The spread requires a slice already assignable to []any, and []string is not: Go's slice types are invariant. You must build a []any and convert element by element, costing one allocation plus one conversion per element.

solid answer

~50 s

`f(s...)` is a hand-off, not a conversion: the compiler requires the argument to be assignable to the variadic parameter's slice type, here `[]any`. A `[]string` is not a `[]any` and cannot become one for free — Go has no covariance for slice types, and the representations genuinely differ, since each `[]any` element is an interface value pairing a type descriptor with data while each `[]string` element is a string header. Reinterpreting one array as the other would be meaningless, so the compiler refuses. The fix is an explicit loop: `args := make([]any, len(locales)); for i, l := range locales { args[i] = l }; report(args...)`. That is one slice allocation plus a conversion per element, linear in the input. The alternative is to drop the dots and pass `report(locales)`, where the callee receives a single `any` holding the whole slice — usually not what you meant.

code

go · 10 lines
go
func report(vals ...any) { /* ... */ }

locales := []string{"en-GB", "fr-FR", "de-DE"}
// report(locales...) // compile error: []string is not []any

args := make([]any, len(locales))
for i, l := range locales {
	args[i] = l
}
report(args...) // one []any plus one conversion per element

go deeper

for a junior

Know the symptom and the fix: the compiler rejects spreading a []string where []any is wanted, and you build a []any with a short loop before spreading that instead.

for a middle

Explain the rule as assignability plus invariance, and say what an interface element physically is compared with a string header, so it is clear why no free reinterpretation exists.

for a senior

Point at the cost and the trap: one allocation plus a conversion per element, and the silent shape change when someone drops the dots to make the build pass. Say where you would hoist the conversion.

for a principal

Own the API consequence: a ...any parameter forces every caller holding a typed slice to allocate and convert. Argue for typed or slice-taking signatures on paths where that adds up.

## The rule the compiler is applying The spread form is defined in terms of **assignability**, not conversion. If a function is declared `func report(vals ...any)`, then `report(x...)` requires `x` to be assignable to `[]any`. That is the entire rule, and it is why so many things you might expect to work do not. A `[]string` is not assignable to `[]any`. It is not a matter of the compiler being unhelpful: **Go has no covariance for composite types.** The fact that `string` is assignable to `any` says nothing about whether `[]string` is assignable to `[]any`, in the same way that it says nothing about `map[string]string` versus `map[string]any`. ## Why the language could not do it for free even if it wanted to The deeper reason is representational. An `any` value is an interface value: two words, one identifying the dynamic type and one pointing at (or holding) the data. A `string` value is a string header: a data pointer and a length. These are different shapes with different meanings, and an array of one is not an array of the other under any reinterpretation. So an implicit `[]string` to `[]any` conversion would have to **allocate a new backing array and convert every element**, silently, at a cost proportional to the input. Go's design position is that an operation with that cost should be visible in the source. The compiler makes you write the loop so the reader can see the work happening. ```go func report(vals ...any) { /* ... */ } locales := []string{"en-GB", "fr-FR", "de-DE"} // report(locales...) // compile error: []string is not []any args := make([]any, len(locales)) for i, l := range locales { args[i] = l } report(args...) ``` ## What the workaround actually costs One `make([]any, n)` — a single slice allocation whose backing array is `n` interface values wide — plus one conversion per element as each string is stored into an `any`. It is linear work; no string bytes are duplicated, because the header inside the interface value still points at the original bytes. If you find yourself writing that loop constantly, hoist it into a tiny generic helper such as `func toAny[T any](s []T) []any` so the conversion lives in one place. That does not remove the linear cost — the work is inherent — it just stops the loop appearing at ten call sites. ## The mistake to avoid: dropping the dots The fastest way to make the compiler stop complaining is to write `report(locales)` — and it compiles, because a `[]string` **is** assignable to `any`, so it becomes one loose argument. But the shape of the call has changed completely: the callee now sees `len(vals) == 1`, with that single element holding the whole slice. For a printing or logging helper this shows up as one bracketed value where you expected several separate ones. Whenever a variadic call starts behaving oddly after someone "fixed a compile error", check whether the three dots went missing. ## Which slices can be spread into `...any` Exactly those assignable to `[]any`: a `[]any` itself, or a named type whose underlying type is `[]any`. Nothing else qualifies, whatever its element type. This is the reason `fmt.Println(args...)` compiles when `args` is already a `[]any` — the standard library's own forwarding helpers all carry the arguments as `[]any` from the outermost call precisely so no conversion is needed on the way down. ## The general lesson The restriction is not about variadic parameters at all. It is the same rule you meet the first time you try to assign a `[]string` to a `[]any` variable, or return one where the other is declared. Variadic calls just happen to be where most Go programmers meet it first, because `...any` is such a common parameter shape in formatting and logging APIs. Learn it as "composite types are invariant in Go" and the variadic case stops looking like a special rule.

  • Which slice types can be spread into a `...any` parameter?
    Only `[]any` itself, or a named type whose underlying type is `[]any`. Assignability is the whole rule, and no other element type satisfies it. That is why `fmt.Println(args...)` compiles when `args` is already a `[]any`, and why forwarding helpers carry their arguments as `[]any` from the outermost call down.
  • What does `report(locales)` give the callee when `report` takes `...any` and `locales` is a `[]string`?
    A variadic parameter of length one, whose single `any` element holds the entire `[]string`. It compiles, and is occasionally what you want, but a printing helper will then emit one bracketed value instead of several separate ones. This is the classic outcome of dropping the three dots to silence a compile error.
  • Is there a way to avoid writing the conversion loop at every call site?
    Write it once in a generic helper, `func toAny[T any](s []T) []any`, and spread its result. That does not remove the linear conversion — the element representations really do differ, so the work is inherent — but it keeps the loop out of ten call sites and gives the cost one obvious name in the codebase.

saying these in an interview costs you the question

  • Expects []string to satisfy []any through covariance
  • Thinks a cast such as ([]any)(locales) is allowed
  • Drops the three dots and does not notice the shape changed
  • Believes the conversion is free because any is an interface
  • Says the compiler ought to insert the loop automatically