JavaRush /Courses /Go SELF /How to Choose an API Contract: error, bool, or (T, bool)

How to Choose an API Contract: error, bool, or (T, bool)

Go SELF
Level 16, Lesson 4
Available

1. Introduction

If programming were simple, we would always return (T, error) and live happily ever after. But life (and users) is more complicated: sometimes “didn’t work” really is an error, and sometimes it’s a normal outcome like “nothing found” or “the condition is false”. A good contract makes the caller’s code simple; a bad one forces you to write weird checks, store “magic values” like -1, and guess what the function author meant.

In Go this is especially noticeable, because the language style encourages explicit contracts and a predictable control flow. And also because errors are part of the API: the moment you choose what to return, you effectively sign an “agreement” with whoever will call your function (including you a week later, when you’ve forgotten what you did there).

Mini rule to start with

Before getting into nuances, you can keep a simple idea in mind: bool is “yes/no”, (T, bool) is “found/not found” (or “worked/didn’t work” without details), error is “didn’t work, and it matters why”.

2. When to return bool

bool is ideal when the function answers a property question: is the number even, is the string empty, does the record match the filter, is the limit exceeded. In such tasks, “false” is not a crash and not a reason to panic; it’s simply the answer. If you try to stuff an error in there, you’ll end up with code that looks as if every false is a tragedy on the level of “the server is down”, while in reality it’s just math.

That’s why the Go standard library has many places that return bool and don’t consider it a “problem”. For example, bufio.Scanner.Scan() returns bool: while scanning continues it returns true, when it’s done it returns false, and the error is checked separately via scanner.Err(). This is a good example of how bool can be part of a neat API that doesn’t clutter the main path with error checks at every step.

Example

Let’s imagine we’re writing a simplest-possible “TodoLite” (in-memory tasks). For now, without a struct—so we’ll store titles in map[int]string, and completion status in map[int]bool.

Predicate: “is the task ID valid?”—this is a typical bool.

package main

import "fmt"

func IsValidID(id int) bool {
	return id > 0
}

func main() {
	fmt.Println(IsValidID(10))  // true
	fmt.Println(IsValidID(-5))  // false
	fmt.Println(IsValidID(0))   // false
}

There’s no reason to return error here. Nobody is going to handle “why the id is invalid” at the business-logic level; most of the time, the rule “id must be greater than zero” is enough.

When bool becomes a bad idea

bool starts getting in the way when the caller needs to understand the reason for rejection. Imagine a function CanCreateTask(title string) bool. If it returns false, you’re left with questions: is the title empty? too long? contains a forbidden character? As a result, you either duplicate checks outside, or you start playing “guessing games” based on indirect signs.

3. When to return (T, bool)

The (T, bool) contract is an excellent compromise for operations like “get a value if it exists”. It’s exactly the idea you already know from map: v, ok := m[key]. If ok == true, then the value was really found, even if the value itself equals the zero value (for example, 0 for int). If ok == false, then the key isn’t there.

The main value of (T, bool) is that it kills “magic values”. In languages where it’s common to return -1 or an empty string for “not found”, you constantly keep in mind that -1 is not an index, it’s a signal. In Go it’s customary to make that signal explicit as a separate bool.

Bad example: magic -1

Here’s a typical “hello from other languages”:

package main

func FindIndexBad(xs []string, target string) int {
	for i, x := range xs {
		if x == target {
			return i
		}
	}
	return -1
}

Does it work? Yes. Is it nice? Not really. The caller must remember about -1, and if they forget, they’ll get a runtime surprise.

Good example: (int, bool)

package main

import "fmt"

func FindIndex(xs []string, target string) (int, bool) {
	for i, x := range xs {
		if x == target {
			return i, true
		}
	}
	return 0, false
}

func main() {
	words := []string{"go", "rust", "java"}

	i, ok := FindIndex(words, "go")
	fmt.Println(i, ok) // 0 true

	i, ok = FindIndex(words, "python")
	fmt.Println(i, ok) // 0 false
}

Notice the “weirdness” that is actually the strength: when ok == false we return 0. That’s not an error, because 0 here is just the zero value, and the “meaning” of the result is carried by ok.

Example from TodoLite: get a title by id

package main

import "fmt"

func GetTitleByID(titles map[int]string, id int) (string, bool) {
	title, ok := titles[id]
	return title, ok
}

func main() {
	titles := map[int]string{
		1: "buy milk",
		2: "read about error",
	}

	title, ok := GetTitleByID(titles, 2)
	fmt.Println(title, ok) // read about error true

	title, ok = GetTitleByID(titles, 99)
	fmt.Println(title, ok) //  false  (title == "")
}

Here it’s especially important that the empty string "" is a valid value of type string (the zero value). If we returned just a string, we couldn’t distinguish “no such task” from “the task exists but the title is empty” (even if our logic forbids that—still, the contract must be honest).

When (T, bool) does not fit

If “not found” is not a normal situation but a scenario error, then (T, bool) will be too quiet. For example, if a user explicitly asks “mark task 99 as done”, but there is no such task, the user needs an explanation (and we need the ability to propagate this problem upward).

4. When to return error

error is needed when failure is meaningful and the reasons matter. First of all—when you perform operations that can fail for different reasons: parsing, validation, constrained computation, interaction with the system (files/network will come later, but the idea is the same).

It’s useful to remember an idea from the Go approach: a well-designed package describes what you can rely on in its errors. The simplest spec is “success: nil, failure: non-nil”. And often that’s enough: we don’t have to guess from a bool what broke; we get a concrete message and can either show it to the user or wrap it with higher-level context.

Example: validating a task title

Let’s say TodoLite cannot create a task with an empty title, and the title cannot be longer than 50 characters. This is not “just false”: these are different reasons, and it’s useful to convey them.

package main

import (
	"errors"
	"fmt"
)

func ValidateTitle(title string) error {
	if len(title) == 0 {
		return errors.New("title is empty")
	}
	if len(title) > 50 {
		return fmt.Errorf("title is too long: %d chars", len(title))
	}
	return nil
}

Here error beats bool because it carries an explanation. And an explanation is half of UX (and half of your debugging speed).

Example: the “mark as done” command as error

Now an important design point: what counts as an “error” in your domain.

If we have a “mark task as done” command and the task is not found, it’s logical to treat that as a command error (the user requested an impossible action).

package main

import (
	"errors"
	"fmt"
)

func MarkDone(done map[int]bool, titles map[int]string, id int) error {
	if _, ok := titles[id]; !ok {
		return errors.New("task not found")
	}
	done[id] = true
	return nil
}

func main() {
	titles := map[int]string{1: "buy milk"}
	done := map[int]bool{}

	err := MarkDone(done, titles, 2)
	fmt.Println(err) // task not found
}

If we returned just a bool, the user would see “didn’t work” and that’s it. And then they’d go to support chat (meaning: to you) to ask “why”.

5. Contract selection criteria

It would be great if there were a universal formula like “if the function is short, return bool”. But no: the semantics of the result matter more. Below is a table that helps you make decisions quickly and consistently across the whole project.

Situation What to return Why
The function answers a property question (“even?”, “valid?”)
bool
false is a normal answer; reasons are usually unnecessary
An operation “get/find if exists”, and “not found” is a normal outcome
(T, bool)
Explicitly separate the “value” from the “presence fact”; avoid magic values
An operation can fail and it matters why (validation, parsing, commands)
error
or
(T, error)
The reason matters to the caller and/or the user
“Not found” is a scenario error (you expected it to exist)
error
You need a human-readable message and the ability to stop the scenario
The result type T has a “dangerous” zero value (0, "", nil) and you can’t distinguish it from “no result”
(T, bool)
or
(T, error)
The contract must be unambiguous

This table looks simple, but it genuinely saves time: you argue with yourself less and write more code.

Decision flowchart

To reinforce the choice not with text but with a “route”, here’s a small flowchart. It’s handy to keep in mind when you write a new function and your hand reaches for “let’s just return error; it’s safer”.

flowchart TD
    A[Need a function contract] --> B{Does the function answer a property question?}
    B -- yes --> C[Return bool]
    B -- no --> D{Does the function 'get/search' for a value?}
    D -- yes --> E{Is the absence of a value a normal outcome?}
    E -- yes --> F["Return (T, bool)"]
    E -- no --> G["Return (T, error) or error"]
    D -- no --> H{Is it important to know the reason for failure?}
    H -- yes --> I["Return error or (T, error)"]
    H -- no --> J["Often (T, bool) is still better than magic"]

Yes, diagrams can be useful too, and no, this isn’t “corporate stuff”: it’s a way to avoid chaos in code.

6. Example: TodoLite and a consistent API

Now let’s assemble several functions so they look like a small library for our TodoLite. We will deliberately use different contracts so we can see the difference “live”.

IsValidID — bool

We’ve already seen it:

func IsValidID(id int) bool {
	return id > 0
}

This is a predicate. bool is perfect.

GetTitleByID — (string, bool)

Getting a value “if it exists”:

func GetTitleByID(titles map[int]string, id int) (string, bool) {
	title, ok := titles[id]
	return title, ok
}

Here, “not found” can be normal (for example, during search).

ParseID — (int, error)

A very typical function for applications: parse an ID from a string (user input).

package main

import (
	"fmt"
	"strconv"
)

func ParseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse id %q: %v", s, err)
	}
	if id <= 0 {
		return 0, fmt.Errorf("id must be positive, got %d", id)
	}
	return id, nil
}

If we returned bool, we would lose useful information: “not a number” and “a number, but a bad one” are different situations.

TryMarkDone — bool or error (depends on meaning)

This is where real “API thinking” starts.

If TryMarkDone is an internal function used as “try to do it and don’t make noise” (for example, during a retry sync), you can do this:

func TryMarkDone(done map[int]bool, titles map[int]string, id int) bool {
	if _, ok := titles[id]; !ok {
		return false
	}
	done[id] = true
	return true
}

But if this is a user command “mark the task as done”, then a silent false is bad UX. In that case you need an error, as we did above, so you can explain the reason for failure.

This is important: the contract depends not on the data type, but on the meaning of the operation.

Nuance: bool and (T, bool) are sometimes better than error

It’s tempting to think that error is always “more correct”, because “this is Go; everything is errors”. But Go is about something else: errors are values, and since they are values, sometimes the value can be something else—not necessarily error.

When “failure” is an expected branch (search, filtering, checking), bool and (T, bool) make code simpler. You don’t create errors, don’t drag strings around, and don’t force the caller to decide “is this a real error or did we just not find it”.

And when the problem is truly an “error”, then error makes the code more honest, because it lifts the reason upward and allows you to stop the scenario or show a message.

7. Common mistakes when choosing error / bool / (T, bool)

Mistake #1: return only T and encode “not found” as a magic value.
Most often it looks like return -1 for an index, return "" for a string, or return 0 for a number. At first it feels convenient, but later you will inevitably forget to check the magic value, and the bug will surface somewhere far away (as always, on a Friday evening). In Go it’s almost always better to return (T, bool) and make the presence fact explicit.

Mistake #2: use bool where the user needs a reason.
A function like CreateTask(title) bool turns any “didn’t work” into a silent “oh well”. Then you’re forced to add extra “why” functions, or duplicate checks in multiple places. If the reason matters (validation, parsing, user command)—return error so the message lives next to where the decision is made.

Mistake #3: return error for predicates and “normal negative answers”.
If you make IsEven(n int) error, then for an odd number you’ll return an error "not even". Logically that’s true, but semantically it’s not a program error. As a result, the calling code will look as if it’s handling failures, while it’s actually just checking a property. That hurts readability and makes code heavyweight.

Mistake #4: mix the semantics of ok from (T, bool) in different places.
If in one place ok means “found”, in another it means “written successfully”, and in a third it means “valid”, then reading the code turns into a detective story. In Go, ok is usually used for “found/present” (especially next to map-like behavior). For “command executed successfully” people more often choose error, so the reason for failure is not hidden.

Mistake #5: with (T, bool), try to use T without checking ok.
This is a cousin of the classic “use the result before checking err”. If you got (title, ok) and ignore ok, sooner or later you’ll treat "" as a real title and start printing empty strings in a report or wondering “why tasks have no titles”. Same rule: check first, then use.

1
Task
Go SELF, level 16, lesson 4
Locked
Access by ID
Access by ID
1
Task
Go SELF, level 16, lesson 4
Locked
Search in a list
Search in a list
1
Task
Go SELF, level 16, lesson 4
Locked
Order quantity
Order quantity
1
Task
Go SELF, level 16, lesson 4
Locked
Title or Mark
Title or Mark
1
Survey/quiz
Errors as values, 16 level, 4 lesson
Unavailable
Errors as values
Errors as values
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION