The Type-Switched Variadic System I Keep Reusing in Go
I keep reaching for the same API shape in Go: a variadic argument list (...any) processed with a
type switch.
I call it my type-switched variadic system. It is not the only way to configure behavior in Go, but for parser/consumer style libraries it has been one of the most productive patterns I use.
If I had to summarize it for future me (or future agents helping me write code), it is this:
Accept a list of typed option values, iterate once, and interpret each value through a type switch into a local config struct.
This post explains the structure, why I like it, where it helps, where it hurts, and what guardrails make it maintainable.
The Core Shape
At the call site, the API feels declarative:
result := Parse(input,
ParserSmartAcronyms(true),
WithNumberSplitting(true),
SnakeCasePartitioner,
)
Inside, the function does something like this:
func Parse(input string, opts ...any) ([]Word, error) {
cfg := defaultParserConfig()
for _, opt := range opts {
switch o := opt.(type) {
case Partitioner:
cfg.Partitioner = o
case PartitionerConfig:
cfg.Partitioner = NewPartitioner(o)
case ParserOption:
o.Apply(&cfg)
}
}
return runPipeline(input, cfg)
}
That loop is the engine.
Why I Prefer This Pattern
1) Ergonomic call sites
You can pass only what matters and ignore everything else. Callers do not have to construct and thread full config structs just to tweak one behavior.
2) Strongly-typed options without giant constructors
Each option type is explicit (consume.StartOffset, consume.CaseInsensitive, MaxLines, etc.), so
behavior is discoverable in docs and completions while still allowing flexible composition.
3) Backward-compatible growth
Adding a new option is often additive: define a new type and a new switch case. Existing call sites keep compiling untouched.
4) Locality of behavior
The option parsing logic lives close to the feature. You can read one function and understand how inputs map to behavior.
Concrete Examples from My Repos
go-pattern: grid composition via operation values
In NewGrid(ops ...any), each argument can be a row op, column op, positioned cell, size
constraint, or callback. The switch applies these operations in sequence to build the final layout.
That lets the call site read like a mini DSL without introducing a separate parser.
Example sources:
golang-diff: compact options with typed wrappers
NewOptions(args ...interface{}) handles different wrappers (TermMode, Interactive, MaxLines,
LineUpFunc, etc.) and maps them into an Options struct. That gives concise calls while keeping
semantic meaning on each value.
Example sources:
strings2: mixed strategy and option interfaces
Parse(input string, opts ...any) accepts both direct strategy values (Partitioner), strategy
configs (PartitionerConfig), and ParserOption interface values. This is a nice middle ground:
functional options and direct typed arguments can coexist.
Example sources:
go-consume (strong example): expressive scanning behavior
UntilConsumer.Consume(from string, ops ...any) is where this pattern really shines for me. It
supports toggles and configuration values like:
consume.Inclusive(true)consume.StartOffset(n)consume.Ignore0PositionMatch(true)consume.CaseInsensitive(true)consume.ConsumeRemainingIfNotFound(true)consume.Escape("\\")consume.Encasing{Start: "(", End: ")"}consume.EscapeBreaksEncasing(true)
You can combine these at the call site to describe behavior clearly without exploding function signatures.
Example sources:
How I Implement It (Repeatable Recipe)
- Start with defaults in local variables or a config struct.
- Loop over
opts ...anyonce. - Use a type switch to map option types to config mutations.
- Run the core algorithm using resolved config.
- Document accepted option types in the function comment.
A practical variant I like is to support both:
- direct typed values (easy, concise), and
- an
Optioninterface withApply(*Config)for richer composition.
Guardrails That Keep It Healthy
This pattern is easy to abuse. These are the rules I try to keep:
Keep option types narrow
Avoid overloaded “god options”. Prefer small types with one obvious meaning.
Avoid ambiguous primitive cases
If you add case int: or case bool:, be careful: ambiguity grows quickly. Named wrapper types
(e.g. MaxLines, StartOffset) are clearer and safer.
Last-write-wins should be intentional
If the same option type appears multiple times, the later one usually overwrites earlier values. That can be useful, but should be documented.
Panic only for programmer errors
In go-consume, empty escape strings panic because they are invalid API usage. For runtime/data
issues, prefer normal return errors.
Keep the switch in one place
Scattering option interpretation across many helpers makes behavior hard to reason about.
Trade-offs and Limitations
No pattern is free.
- Compile-time guarantees are partial: invalid option combinations are often caught at runtime.
- Discoverability depends on docs: users need clear comments listing accepted option types.
- Large switches can sprawl: if a function grows too many cases, refactor by option domain.
For very strict APIs, plain config structs or pure functional options may be better.
Why This Works Well with AI/Agent Collaboration
When I ask an agent to extend one of these libraries, this pattern gives a clear target:
- Add a new typed option.
- Add a switch case.
- Update docs/examples.
- Add tests for default behavior + option-enabled behavior.
It is explicit and mechanical enough that agents can follow it reliably, while still producing human-friendly call sites.
Prompt Template I Can Reuse Later
When I want future agents to produce code in this style, I can be direct:
Use a type-switched variadic option system. Accept
opts ...any. Parse options via oneforloop with atype switchinto local config. Prefer named wrapper types over raw primitives. Keep defaults explicit and document accepted option types. Add tests for conflicting options and last-write-wins behavior.
That usually gets me close to what I want.
Closing
I like this pattern because it balances ergonomics, type-driven clarity, and evolution over time. In parser/consumer-heavy code, it often reads naturally and adapts well.
It is not mandatory, and I still use plain configs when that is simpler. But when I need a compact, expressive surface that can grow steadily, the type-switched variadic system keeps proving itself.