DEV Community

TKD Engineer
TKD Engineer

Posted on

Go's any is a Lie: The Case for Sum Types and Truthful API Contracts

When we define a function that accepts map[string]any, we are telling the user: 'Give me anything.' But in reality, our code only handles string or int. This is a broken contract. It forces us to move safety checks from the compiler to the runtime, trading 'red squiggles' in the IDE for err != nil blocks in production. It's time for Go to embrace First-Class Sum Types.

How it currently works (The "Boilerplate" way):
We are forced to write "safety" code that should be handled by the compiler.

type List map[string]any

func Compile(vars List) error {
  for _, val := range vars {
    switch val.(type) {
      case string, int:
        // logic here
      default:
        // This error only happens at RUNTIME.
        return fmt.Errorf("invalid type: %T", val)
    }
  }
  return nil
}
Enter fullscreen mode Exit fullscreen mode

How it should work (The "Truthful" way):
The interface becomes a strict contract that the IDE and Compiler can enforce.

// The contract is defined at the type level
type List map[string]interface{ string | int }

func Compile(vars List) {
  for _, val := range vars {
    // No 'error' return or 'default' case needed. 
    // The compiler guaranteed the types.
    switch v := val.(type) {
      case string: ...
      case int: ...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In modern development, an interface should be a contract.

Using any is a broken contract. It tells the user 'Give me anything', but then my function fails at runtime if they actually do. That is a lie in the API design.

A "General Interface" (like interface{string|int}) is a truthful contract. It tells the programmer exactly what is allowed before they even hit 'Save'.

Go is a statically typed language. By forcing me to use any for mixed-type maps, we are regressing to a 'JavaScript-like' experience where I, or someone else, has to look at the source code or documentation to know what a function accepts.

Red squiggles in VS Code are better than panic or err != nil at runtime. Type constraints allow the IDE to provide Autocompletion and Static Analysis.

Runtime errors are expensive; compile-time errors are free.

If a junior developer tries to pass a nested map into your "Flat Map", the compiler should stop them instantly. If we use any, that bug might survive all the way to a production environment before the switch default case triggers an error.

If I use iota, I can still pass a random int to a function expecting my "Enum". A sum type or enum would allow the compiler to check that every possible case is handled in a switch statement.


Just use any and a type switch.

Using any shifts the responsibility of type safety from the compiler to the runtime. My goal is to catch errors during development, not during execution. If I use any, a developer can pass a nested map into a function expecting a flat list, and the IDE won't warn them. That leads to a 'broken contract' where the function signature says 'Give me anything' but the implementation says 'Actually, I only wanted these three things.' I'm looking for a way to make the API signature match the implementation requirements.

Go is meant to be simple, don't overcomplicate it.

Simplicity is also about predictability. Forcing developers to write manual validation boilerplate (like if type != x { return err }) for every function call increases 'noise' in the codebase. By allowing the compiler to handle these constraints, the business logic becomes cleaner and the intent of the code becomes more transparent to anyone reading it later.

This is just a Union Type/Sum Type, Go doesn't have those.

Exactly. Go already allows these 'Type Sets' within Generic constraints. The inconsistency is that we can define these sets to validate a function, but we can't use them to define a variable's storage. I'm advocating for making the existing Type Set logic first-class so that mixed-type maps can benefit from the same static analysis that Generics currently enjoy.


The Problem: API "Lies"
When we use map[string]any, we are essentially telling the user, "You can put anything here." But that is a lie if our internal logic only supports a few type constraints, (like string | int for example).

The "DX" (Developer Experience) Argument:
Red Squiggles > Logs: We want junior devs to see an error in an IDE (like VSCode) the moment they try to pass a nested map, not find a nil pointer or a "wrong type" error in the production logs.

Static Analysis: Using any turns Go into a "Guessing Game." Proper type constraints allow the IDE to provide actual help via autocompletion and type warnings.

The Cost of any: In a statically typed language, any should be the last resort, not the default for mixed-type collections.

Feature Go (any) TypeScript (|) Rust (enum)
API Contract "The Lie": Claims to take anything, but fails at runtime. Truthful: Defines exact allowed types. Strict: Types are explicitly wrapped and checked.
Feedback Loop Logs: Errors found via err != nil in production. IDE: "Red squiggles" during development. Compiler: Won't build until all cases are handled.
Exhaustiveness Manual: Requires a default case to catch "impossible" types. Optional: Can be enforced with strict linting. Native: The compiler forces you to handle every variant.
Validation Runtime: Uses reflect or type switches with error returns. Static: Validated before the code ever runs. Static: Zero-cost abstractions for type safety.

Currently, with iota constants, if you add a new "Type" to your code, the compiler won't tell you that you forgot to update a switch statement somewhere else. If Go had the syntax I'm proposing (interface{TypeA | TypeB}), the compiler could technically check if you've handled every case.

My goal is to eliminate the else { return error } block in my second example. I believe the compiler should handle that check, not my runtime code.

Top comments (0)