1. The role of filters and their place in the output pipeline
If a CLI prints a task list “as is”, it very quickly turns into an endless scroll of text—enjoyable only if you like suffering. Filters solve a simple problem: they answer the question “what do we show?” without interfering with “how do we show it?”. And that’s the key point: filtering is not conditional printing with fmt.Println, but a strict data-preparation stage that works on structures ([]Task), not on strings.
Imagine a user wants to “show only completed tasks”, or “show tasks whose title contains the word ‘taxes’”, or “everything created after a certain date”. These are classic filter use cases. If you design them predictably, your CLI will be convenient both for people and for scripts. If you design them “with magic”, users will feel like the app has a mind of its own.
Let’s draw where filters live in the output pipeline (it’s important to keep this in mind so you don’t mix stages):
flowchart LR A[Retrieved tasks from storage] --> B[Applied filters] B --> C[Sorted] C --> D[Rendered table/json]
2. Status flags --done and --undone
The --done and --undone flags look simple—until you ask two uncomfortable questions.
First: what do we do if the user didn’t specify anything? A CLI is about contracts, so the answers must be defined in advance and be consistent across runs. The healthiest default: if no filters are specified, show everything.
Second: what do we do if the user specified both flags? The combination --done --undone looks like “show done and undone”, i.e. “show everything”. But the problem is that the user might have enabled both by accident, and we’d end up with behavior that looks like the default—so the mistake goes unnoticed. That’s bad UX: the person thought they were filtering, but no filtering happened. That’s why a common rule is: --done and --undone together is an input error (usage error) that we catch before running the command.
Let’s lock the contract in as a table—useful both for us and for the future README:
| Flags | What we show | Why |
|---|---|---|
| none | all tasks | predictable default |
|
only Done=true | explicitly requested |
|
only Done=false | explicitly requested |
|
error | conflict, likely user typo |
Now, the minimal structure for filters. Important: filters are input parameters for the filtering stage, not “logic smeared across the codebase”.
package main
type Filters struct {
DoneOnly bool
UndoneOnly bool
Contains string
}
3. Filters --contains and --since
--contains: a simple substring without “magic”
The --contains filter usually means “show tasks whose title contains a substring”. And here we get tempted to “make it convenient”: ignore case, trim spaces, support regular expressions, transliteration, word search. Stop. We’re designing a CLI for beginners and for a stable contract. That means the filter must be simple, transparent, and predictable.
The most honest option: strings.Contains(title, query) and that’s it. If we want “no surprises”, we must explicitly define what an empty string means. Typically --contains "" is not useful, so the contract is: an empty string means “filter not set”, and we don’t filter by substring at all.
One more important decision: case sensitivity. If you make it case-insensitive, it may feel “more convenient”, but it introduces hidden processing (strings.ToLower) and raises locale questions (which we’re not touching today). So for a basic course, it’s better to choose the straightforward option: the search is case-sensitive. And if we want to extend it later, that will be an explicit contract version—not “it just works somehow”.
Mini-predicate for contains:
package main
import "strings"
func containsTitle(title, q string) bool {
if q == "" {
return true // filter not set
}
return strings.Contains(title, q)
}
--since: dates, parsing, and clear errors
--since is a filter meaning “show tasks created no earlier than the specified date”. And the most important part here is not the date itself, but the format and the behavior on errors. If you accept a date as a string, you must choose a format that’s easy to type and easy to read. For CLIs, an ISO-like format YYYY-MM-DD almost always wins, because it sorts as text and looks the same across countries.
In Go, parsing such dates is done via the magic layout "2006-01-02". It looks like a safe combination, but it’s simply a “reference date” that Go uses to understand where the year, month, and day are.
--since has another typical issue: the filter is optional, and we need to distinguish between “the user didn’t set a date” and “the user set a date, but it’s the zero value”. A convenient way is to return a triple: (time.Time, bool, error)— the value, a “was it set?” flag, and an error.
package main
import (
"fmt"
"time"
)
func parseSince(s string) (time.Time, bool, error) {
if s == "" {
return time.Time{}, false, nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return time.Time{}, false, fmt.Errorf("bad --since %q, expected YYYY-MM-DD", s)
}
return t, true, nil
}
Notice: we don’t continue “hoping for the best”. An invalid date is an input error. In a CLI, it’s better to fail immediately and honestly than to pretend you understood the user.
To filter by date, our task needs CreatedAt:
package main
import "time"
type Task struct {
ID int
Title string
Done bool
CreatedAt time.Time
}
And the check:
package main
import "time"
func isAfterSince(createdAt time.Time, since time.Time, sinceSet bool) bool {
if !sinceSet {
return true
}
return !createdAt.Before(since) // createdAt >= since
}
4. Implementation: predicates, validation, and applying filters
Predicate as a contract: func(Task) bool
When you have more than one filter, a classic trap appears: “let’s just write 10 conditions right in the for loop and print somewhere in the middle”. It will work, but reading it later will hurt. It’s much nicer to keep filtering as “a function that answers whether an element matches”.
Such a function is often called a predicate. In our case, the predicate looks like func(Task) bool. We can write a matches(t, f) function and stack rules in a clear order. A good style is early return false, because it reads like “if the rule is violated, the task doesn’t match”.
package main
import "strings"
type Filters struct {
DoneOnly bool
UndoneOnly bool
Contains string
}
func matches(t Task, f Filters) bool {
if f.DoneOnly && !t.Done {
return false
}
if f.UndoneOnly && t.Done {
return false
}
if f.Contains != "" && !strings.Contains(t.Title, f.Contains) {
return false
}
return true
}
Now filtering itself becomes almost “dumb”, and that’s what makes it great: it doesn’t contain domain-specific conditions, it just applies the predicate to all elements and collects the result.
package main
func filterTasks(tasks []Task, f Filters) []Task {
out := make([]Task, 0, len(tasks))
for _, t := range tasks {
if matches(t, f) {
out = append(out, t)
}
}
return out
}
Note the make(..., 0, len(tasks)): we assume in advance that in the “worst case” all tasks pass, and we ask Go to allocate capacity for that case. It’s a small optimization, but it also makes the intent clear: “we’re building a new list”.
Input validation and “fail fast”
Filters are user input. User input can be wonderful, but more often it’s “oops”. So we validate before executing the command logic: if the input is conflicting or invalid, we return an error and don’t touch business logic. That saves time, nerves, and the number of mysterious states.
The main conflict today is --done and --undone together. We check that explicitly. If other filters are added later (for example, --since and --until), the approach is the same: the conflict must be described and checked.
package main
import "fmt"
func validateFilters(f Filters) error {
if f.DoneOnly && f.UndoneOnly {
return fmt.Errorf("conflicting flags: --done and --undone")
}
return nil
}
Why is this better than “quietly resolving it”? Because a CLI is a tool. A tool that silently “fixes” your input can “fix” it in a way you didn’t expect. And then you’ll build automation on top of it—and you’ll end up with a robot that confidently does the wrong things.
Wiring filters into the list command
Now let’s assemble this into a small, cohesive fragment: how command flags turn into Filters, get validated, and then applied. I’ll show it in chunks of 5–10 lines so the code stays readable while still forming a clear picture.
Assume we already have a CLI skeleton with a list subcommand (we discussed flag.FlagSet earlier), and we’re now adding filters to it. Let’s start with parsing flags:
package main
import "flag"
func parseListFlags(args []string) (Filters, string, error) {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
done := fs.Bool("done", false, "show only done tasks")
undone := fs.Bool("undone", false, "show only undone tasks")
contains := fs.String("contains", "", "substring in title")
if err := fs.Parse(args); err != nil {
return Filters{}, "", err
}
return Filters{DoneOnly: *done, UndoneOnly: *undone, Contains: *contains}, fs.Arg(0), nil
}
There’s one “weird” moment here: I also returned fs.Arg(0). This is just an illustration that positional arguments can follow flags. In our specific list command they might not be needed; the key point is that Parse separates flags from arguments.
Now let’s add --since. It’s more convenient to use String for it, and then parse via parseSince to control the error cleanly:
package main
import "flag"
type ListOptions struct {
Filters Filters
Since string
Format string
}
func parseListOptions(args []string) (ListOptions, error) {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
done := fs.Bool("done", false, "show only done tasks")
undone := fs.Bool("undone", false, "show only undone tasks")
contains := fs.String("contains", "", "substring in title")
since := fs.String("since", "", "created on/after date (YYYY-MM-DD)")
if err := fs.Parse(args); err != nil {
return ListOptions{}, err
}
return ListOptions{
Filters: Filters{DoneOnly: *done, UndoneOnly: *undone, Contains: *contains},
Since: *since,
}, nil
}
Notice: I didn’t immediately put time.Time into ListOptions. Why? Because parseListOptions is “raw input”, while time.Parse is already normalization/validation. Sometimes it’s convenient to separate these stages: first extract strings and booleans, then separately turn them into “real data”.
Now let’s combine validation and application. As an example, we’ll make a function that receives tasks (in a real app—from storage), applies filters, and returns the filtered list.
package main
func applyFilters(tasks []Task, opt ListOptions) ([]Task, error) {
if err := validateFilters(opt.Filters); err != nil {
return nil, err
}
sinceTime, sinceSet, err := parseSince(opt.Since)
if err != nil {
return nil, err
}
out := make([]Task, 0, len(tasks))
for _, t := range tasks {
if !matches(t, opt.Filters) {
continue
}
if sinceSet && t.CreatedAt.Before(sinceTime) {
continue
}
out = append(out, t)
}
return out, nil
}
This shows an important architectural decision: filtering happens on Task structures, not on output strings. That means we can pass this list equally well into a table, JSON, or any other format without rewriting filters.
To make it easier to “feel” how this works, you can create a mini sample dataset:
package main
import "time"
func seedTasks() []Task {
return []Task{
{ID: 1, Title: "Pay rent", Done: true, CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)},
{ID: 2, Title: "Buy milk", Done: false, CreatedAt: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)},
{ID: 3, Title: "Read Go book", Done: false, CreatedAt: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)},
}
}
Yes, the date 2026-01-10 is just a few days ago relative to our current date (Friday, January 16, 2026). Examples like this are good because it’s easy to visually verify what should pass the --since=2026-01-01 filter.
5. Common mistakes when designing filters
Mistake #1: filtering “inside the renderer”.
Beginners often start doing something like: “if the task matches—print the line, otherwise—skip it”. As a result, filter logic gets smeared into fmt.Printf, and a week later you can’t add JSON output without copy-pasting all the conditions. The right habit: filters always work on []Task, and the renderer receives an already prepared list.
Mistake #2: “smart” defaults that behave however they want.
For example, making --done --undone silently turn into “show everything”. It may look convenient, but it breaks diagnostics: the user made a mistake—and you didn’t say so. For a CLI, it’s better to be strict and clear: a conflict is an input error.
Mistake #3: not distinguishing between “filter not set” and “value is the zero value”.
With --since this is especially noticeable. If you store since as a time.Time and just compare it to the zero time, the code starts to look like a magic ritual: “if since is not equal to zero”. It’s much more readable to return a sinceSet flag and check it explicitly.
Mistake #4: continuing execution after a date parse error.
Sometimes people write: “if time.Parse fails—fine, we’ll take the zero date”. That turns invalid input into a confusing result. In a CLI, an invalid date format should produce an error, otherwise the user will never learn to type the date correctly.
Mistake #5: trying to make --contains “too smart” without a contract.
Case-insensitivity, tokenization, regex—these can all be useful, but if you haven’t nailed down the contract, users will start guessing how the search works. At the start, stick to the simple rule strings.Contains, and everything else only deliberately and explicitly, with documentation.
GO TO FULL VERSION