Boolean Blindness in Go: When true, false, true Tells You Nothing

go dev.to

Quick — what does this call do?

v.validateDocument(raw, yaml.Unmarshal, "YAML", "dsl_version",
    accepted, "control", true, "Fix control to match DSL schema", opts...)
Enter fullscreen mode Exit fullscreen mode

What's the true? Is it strict? isYAML? overwrite? allowSymlink? You have to count parameters to find out. And if someone swaps the "YAML" and "dsl_version" strings, the compiler won't catch it — they're both string.

This is boolean blindness: when a function takes bool parameters whose meaning is invisible at the call site. The compiler sees true. The developer sees true. Neither sees "this enables YAML mode."

We found this pattern in five places and fixed each one differently.

1. Adjacent Bool Parameters — The File Safety Trap

Before: Three bools control security behavior

func SafeCreateFile(path string, perm os.FileMode, overwrite bool, allowSymlink bool) (*os.File, error) {
    // ...
}

// At the call site:
f, err := fsutil.SafeCreateFile(outputPath, 0o600, false, false)
Enter fullscreen mode Exit fullscreen mode

What do the two false values mean? The reader must look up the function signature. And if someone writes SafeCreateFile(path, 0o600, true, true), they've just enabled file overwriting AND symlink following — two security-sensitive behaviors activated by an unreadable pair of booleans.

After: Named options struct

type WriteOptions struct {
    Perm         os.FileMode
    Overwrite    bool
    AllowSymlink bool
}

func DefaultWriteOpts() WriteOptions {
    return WriteOptions{
        Perm:         0o600,
        Overwrite:    false,
        AllowSymlink: false,
    }
}

func ConfigWriteOpts() WriteOptions {
    return WriteOptions{
        Perm:         0o644,
        Overwrite:    true,
        AllowSymlink: false,
    }
}

func SafeCreateFile(path string, opts WriteOptions) (*os.File, error) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The call site is now self-documenting:

// Sensitive output: conservative defaults
f, err := fsutil.SafeCreateFile(outputPath, fsutil.DefaultWriteOpts())

// Config file: allow overwrite, broader permissions
f, err := fsutil.SafeCreateFile(configPath, fsutil.ConfigWriteOpts())

// Custom: explicit about what's enabled
f, err := fsutil.SafeCreateFile(path, fsutil.WriteOptions{
    Perm:         0o600,
    Overwrite:    true,   // explicitly named
    AllowSymlink: false,  // explicitly named
})
Enter fullscreen mode Exit fullscreen mode

Named factory functions (DefaultWriteOpts, ConfigWriteOpts) capture common combinations. Most callers use a factory instead of constructing the struct — the boolean values are encapsulated behind a meaningful name.

Security benefit: A code reviewer sees ConfigWriteOpts() and knows it allows overwrite. They don't have to count booleans. If a new option is added (say, AppendMode), existing call sites don't change — they get the default value from the factory.

2. The quiet Bool — Wrong Abstraction Level

Before: Every renderer checks a boolean

func writeOutput(w io.Writer, format OutputFormat, quiet bool, delta Delta) error {
    if quiet {
        return nil
    }
    // ... render output
}

func RenderJSON(eval Evaluation, version string, w io.Writer, quiet bool) error {
    if quiet {
        return nil
    }
    // ... render JSON
}

func printScaffoldSummary(w, stderr io.Writer, req SummaryRequest, quiet bool) {
    if quiet {
        return
    }
    // ... print summary
}
Enter fullscreen mode Exit fullscreen mode

Five renderers. Each takes quiet bool. Each has if quiet { return nil } at the top. The quiet behavior is identical in every renderer — but it's implemented five times.

After: Resolve the writer, not the boolean

func ResolveStdout(w io.Writer, quiet bool, format OutputFormat) io.Writer {
    if quiet && !format.IsMachineReadable() {
        return io.Discard
    }
    return w
}
Enter fullscreen mode Exit fullscreen mode

The renderer no longer knows about quiet mode:

// BEFORE: renderer decides whether to write
func writeOutput(w io.Writer, format OutputFormat, quiet bool, delta Delta) error {
    if quiet { return nil }
    // ...
}

// AFTER: renderer writes to whatever it receives
func writeOutput(w io.Writer, format OutputFormat, delta Delta) error {
    // ... always writes — if w is io.Discard, output is silently dropped
}
Enter fullscreen mode Exit fullscreen mode

The caller resolves the writer once:

w := compose.ResolveStdout(cmd.OutOrStdout(), flags.Quiet, format)
writeOutput(w, format, delta)
Enter fullscreen mode Exit fullscreen mode

Why this is better than removing the bool: The quiet decision is made once at the CLI boundary, not repeated in every renderer. The renderers are "dumb pipes" — they write to their writer without knowing or caring whether it's stdout or /dev/null. Adding a sixth renderer requires zero quiet-mode code.

The format-aware twist: ResolveStdout preserves JSON output even in quiet mode (IsMachineReadable()). A CI pipeline running stave apply --quiet --format json | jq .findings gets the JSON it needs. Only text output is silenced. This nuance was previously implemented inconsistently across the five renderers — one discarded JSON in quiet mode, breaking downstream tools.

3. Nine Positional Parameters — The Swap Hazard

Before: Strings and bools that look identical

func (v *Validator) validateDocument(
    raw []byte,
    unmarshal func([]byte, any) error,
    formatName string,      // "YAML" or "JSON"
    versionField string,    // "dsl_version" or "schema_version"
    accepted []string,      // ["ctrl.v1"] or ["obs.v0.1"]
    kind string,            // "control" or "observation"
    isYAML bool,            // true for YAML, false for JSON
    defaultAction string,   // "Fix control to match DSL schema"
    opts ...Option,
) (*diag.Result, error) {
Enter fullscreen mode Exit fullscreen mode

Nine parameters. Four are string. Swapping formatName with versionField compiles fine — both are string. The caller:

v.validateDocument(raw, yaml.Unmarshal, "YAML", "dsl_version",
    accepted, "control", true, "Fix control to match DSL schema", opts...)
Enter fullscreen mode Exit fullscreen mode

Which string is formatName? Which is kind? Which is defaultAction? You have to count. And if you miscount, the validator uses "YAML" as the version field name and "dsl_version" as the human-readable format label.

After: Config struct with named fields

type docConfig struct {
    Unmarshal     func([]byte, any) error
    FormatName    string
    VersionField  string
    Accepted      []string
    Kind          string
    IsYAML        bool
    DefaultAction string
}

func (v *Validator) validateDocument(raw []byte, cfg docConfig, opts ...Option) (*diag.Result, error) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The call site:

v.validateDocument(raw, docConfig{
    Unmarshal:     yaml.Unmarshal,
    FormatName:    "YAML",
    VersionField:  "dsl_version",
    Accepted:      []string{string(kernel.SchemaControl)},
    Kind:          string(schemas.KindControl),
    IsYAML:        true,
    DefaultAction: "Fix control to match DSL schema",
}, opts...)
Enter fullscreen mode Exit fullscreen mode

Every value is labeled. FormatName: "YAML" can't be confused with Kind: "control". Adding a new parameter requires no change to existing call sites — it gets the zero value default.

4. Two Adjacent Bools — The Silent Swap

Before: apply and force are next to each other

func resolveMode(apply, force bool, archiveDir string) (Mode, bool) {
    if !apply || !force {
        return ModePreview, false
    }
    if archiveDir != "" {
        return ModeArchive, true
    }
    return ModePrune, true
}
Enter fullscreen mode Exit fullscreen mode

At the call site:

mode, proceed := resolveMode(true, false, archiveDir)
Enter fullscreen mode Exit fullscreen mode

Which true is apply? Which false is force? Swap them and the logic inverts — but it compiles fine.

After: Named parameters via struct

type ModeRequest struct {
    Apply      bool
    Force      bool
    ArchiveDir string
}

func resolveMode(req ModeRequest) (Mode, bool) {
    if !req.Apply || !req.Force {
        return ModePreview, false
    }
    if req.ArchiveDir != "" {
        return ModeArchive, true
    }
    return ModePrune, true
}
Enter fullscreen mode Exit fullscreen mode

Call site:

mode, proceed := resolveMode(ModeRequest{
    Apply:      true,
    Force:      false,
    ArchiveDir: archiveDir,
})
Enter fullscreen mode Exit fullscreen mode

No swap possible. Apply: true is unambiguous. If someone adds a DryRun bool to ModeRequest, existing call sites don't change.

5. The FindingWriterFactory — Hidden Bool in a Function Type

Before: Bool parameter in a type alias

type FindingWriterFactory = func(OutputFormat, bool) (FindingMarshaler, error)
Enter fullscreen mode Exit fullscreen mode

What's the bool? At every call site:

marshaler, err := factory(format, false)
Enter fullscreen mode Exit fullscreen mode

Is false JSON mode? Pretty print? Strict validation? You have to read the factory implementation to find out.

After: Explicit at the call site

The fix here wasn't changing the type — it was eliminating the bool's reason for existing. The bool was isJSONMode, which duplicated information already in OutputFormat:

// BEFORE: redundant bool
marshaler, err := factory(format, cfg.IsJSONMode)

// AFTER: format already carries the information
marshaler, err := factory(format, false)  // isJSONMode removed from config
Enter fullscreen mode Exit fullscreen mode

The false literal is still there, but it's now always false — the JSON mode logic was consolidated into OutputFormat.IsJSON(). The next step is to remove the parameter entirely and update the function type.

The lesson: Before adding a parameter struct, ask whether the bool duplicates information that's already available. Sometimes the fix is removing the bool, not wrapping it.

The Decision Tree

Does the function take 2+ adjacent bools?
├── YES → Replace with an options struct
│
Does the function take 4+ parameters including bools and strings?
├── YES → Replace all params with a config struct
│
Is the bool controlling "output/no output"?
├── YES → Resolve the io.Writer at the boundary, not in the renderer
│
Does the bool duplicate info available elsewhere?
├── YES → Remove the bool, use the existing source
│
Is it a single bool with a clear name in a 2-3 param function?
└── YES → Keep it. Not everything needs a struct.
Enter fullscreen mode Exit fullscreen mode

That last case matters. This is fine:

func (s Severity) Gte(other Severity) bool { return s >= other }
func (a Audience) IsExternal() bool { return a != AudiencePrivate }
func (ctl *ControlDefinition) IsEvaluatable() bool { ... }
Enter fullscreen mode Exit fullscreen mode

Single bool return values and single bool parameters on short functions are readable. The problem is adjacent bools, positional bools, and bools that control behavior the caller should express differently.


These boolean blindness fixes were applied across Stave, a Go CLI for offline security evaluation. The WriteOptions struct replaced 3 security-sensitive bools with named fields and factory defaults. The quiet-to-io.Discard pattern eliminated 5 duplicate if quiet { return } checks. The docConfig struct replaced a 9-parameter function with labeled fields.

Source: dev.to

arrow_back Back to Tutorials