Tea — a strongly typed scripting language
Tea
DocsPlaygroundExamplesReferenceCommunity
GitHub

Tea Language

A strongly typed scripting language for native tools.

© 2026 Tea Language.

Agent documentationContributingCommunityRepository

Get Started

InstallationGetting StartedCLI

Tutorial

Build a CLI with Tea1. Display your first task2. Display several tasks3. Track task status4. Accept commands5. Read saved tasks6. Add tasks7. Complete tasks8. Organize and test9. Build your executable

Language

SyntaxTypesFunctionsStructsGenericsPattern MatchingError HandlingModulesTesting

Project

Code StyleContributing

Read as Markdown

Tutorial

Complete tasks

Find a task by number and update its status safely.

Chapter 7 of 9 · Build a CLI with Tea

Choose a chapter
  1. Build a CLI with Tea
  2. 1. Display your first task
  3. 2. Display several tasks
  4. 3. Track task status
  5. 4. Accept commands
  6. 5. Read saved tasks
  7. 6. Add tasks
  8. 7. Complete tasks
  9. 8. Organize and test
  10. 9. Build your executable

Start with chapter 6's program and its four saved tasks. We'll add done NUMBER, keeping task numbers stable as tasks move out of the open view.

Convert a number into a task

Add use parse from "std.parse" to your imports, and add InvalidNumber(input: String) to TaskError.

Add complete_task below add_task using the complete checkpoint. Its input is a string because command-line arguments are text. parse.try_int converts it into an Int, or throws parse.ParseError.InvalidInt.

We first require between 1 and 19 characters, all ASCII digits. After parsing, we check that the value is between 1 and the list length, and that converting it back to text exactly reproduces the input. This accepts the numbers displayed by list, while rejecting signs, leading zeros, and excessively large values. Validation happens before any list access or mutation.

The catch inside complete_task translates a standard-library parse error into our program's TaskError.InvalidNumber. That keeps the command's error message focused on what the user needs: an existing task number.

Update the task

The last part of complete_task is:

const task = tasks[number - 1]
if task.done
  return false
end
task.done = true
true

Subtracting one converts the displayed number into a zero-based index. Structs, like lists, have shared reference semantics: changing task.done also changes that task in tasks. const task prevents assigning a different struct to the binding, but permits changing a field.

The boolean return value tells the command whether anything changed. A task that's already complete returns false, so repeating the command doesn't rewrite the file.

Connect the command

Inside run, add a done branch before the final usage() call. Require exactly two arguments, load the tasks, and pass the second argument to complete_task. Save only when it returns true.

Update the usage text to include done NUMBER. Add a TaskError.InvalidNumber arm to the outer catch block; it prints the rejected input, suggests list --all, and exits with status 2. The complete checkpoint contains both changes.

Complete a task

tea tasks.tea done 2
Completed task 2.
tea tasks.tea list
1. [ ] Learn Tea
4. [ ] Write a test
tea tasks.tea list --all
1. [ ] Learn Tea
2. [x] Build a CLI
3. [x] Take a break
4. [ ] Write a test

The open view still labels the fourth task 4, rather than renumbering it 2. Run done 2 again and you'll get Task 2 is already complete.

Complete checkpoint

Keep tasks.txt as it is and replace tasks.tea with this source if needed:

Complete tasks.tea for this chapter

tasks.tea

use args from "std.args"
use fs from "std.fs"
use string from "std.string"
use parse from "std.parse"

struct Task {
  title: String
  done: Bool
}

def format_task(task: Task, number: Int) -> String
  const marker = if(task.done) "[x]" else "[ ]"
  `${number}. ${marker} ${task.title}`
end

def show_tasks(tasks: List[Task], show_all: Bool) -> Void
  if @len(tasks) == 0
    @println("No tasks yet.")
    return
  end

  var number = 1
  var shown = 0

  for task in tasks
    if show_all ||!task.done
      @println(format_task(task, number))
      shown = shown + 1
    end

    number = number + 1
  end

  if shown == 0
    @println("No open tasks.")
  end
end

error TaskError {
  InvalidData(line: Int)
  InvalidTitle
  InvalidNumber(input: String)
}

def valid_title(title: String) -> Bool
  const without_spaces = string.replace(title, " ", "")
  const without_tabs = string.replace(without_spaces, "\t", "")
  @len(without_tabs) > 0 &&!string.contains(title, "\n") &&!string.contains(title, "\r")
end

def decode_tasks(content: String) -> List[Task] ! TaskError
  var tasks: List[Task] = []
  var number = 1

  for line in string.lines(string.replace(content, "\r\n", "\n"))
    if @len(line) < 5 ||!(string.starts_with(line, "[ ] ") || string.starts_with(line, "[x] "))
      throw TaskError.InvalidData(number)
    end

    const title = line[4..@len(line)]

    if ! valid_title(title)
      throw TaskError.InvalidData(number)
    end

    @append(tasks, Task(title: title, done: string.starts_with(line, "[x] ")))
    number = number + 1
  end

  tasks
end

def load_tasks(file: String) -> List[Task] ! { fs.FsError, TaskError }
  if ! fs.exists(file)
    return[]
  end

  decode_tasks(fs.read_file(file))
end

def encode_tasks(tasks: List[Task]) -> String
  var content = ""

  for task in tasks
    const marker = if(task.done) "[x]" else "[ ]"
    content = content + `${marker} ${task.title}\n`
  end

  content
end

def save_tasks(file: String, tasks: List[Task]) -> Void ! fs.FsError
  fs.write_file_atomic(file, encode_tasks(tasks))
end

def add_task(tasks: List[Task], title: String) -> Void ! TaskError
  if ! valid_title(title)
    throw TaskError.InvalidTitle
  end

  @append(tasks, Task(title: title, done: false))
end

def complete_task(tasks: List[Task], input: String) -> Bool ! TaskError
  if @len(input) == 0 || @len(input) > 19
    throw TaskError.InvalidNumber(input)
  end

  var i = 0

  while i < @len(input)
    if ! string.contains("0123456789", input[i])
      throw TaskError.InvalidNumber(input)
    end

    i = i + 1
  end

  const number = try parse.try_int(input) catch err
    case is parse.ParseError.InvalidInt
      throw TaskError.InvalidNumber(err.input)
    case _
      throw TaskError.InvalidNumber(input)
  end

  if number < 1 || number > @len(tasks) || @to_string(number) != input
    throw TaskError.InvalidNumber(input)
  end

  const task = tasks[number - 1]

  if task.done
    return false
  end

  task.done = true
  true
end

def help() -> Void
  @println("Usage: tasks [list [--all] | add \"title\" | done NUMBER | --help]")
  @println("Tasks are numbered from 1. Listing shows open tasks; --all includes completed tasks.")
end

def usage() -> Void
  @eprintln("tasks: invalid arguments; run tasks --help for usage")
  @exit(2)
end

def run() -> Void ! { fs.FsError, TaskError }
  var argv = args.all()

  if @len(argv) == 0
    argv = ["list"]
  end

  if argv[0] == "--help" && @len(argv) == 1
    help()
    return
  end

  if argv[0] == "list"
    if @len(argv) > 2 ||(@len(argv) == 2 && argv[1] != "--all")
      usage()
      return
    end

    const tasks = load_tasks("tasks.txt")
    show_tasks(tasks, @len(argv) == 2)
    return
  end

  if argv[0] == "add" && @len(argv) == 2
    const tasks = load_tasks("tasks.txt")
    add_task(tasks, argv[1])
    save_tasks("tasks.txt", tasks)
    @println(`Added task ${@len(tasks)}.`)
    return
  end

  if argv[0] == "done" && @len(argv) == 2
    const tasks = load_tasks("tasks.txt")

    if complete_task(tasks, argv[1])
      save_tasks("tasks.txt", tasks)
      @println(`Completed task ${argv[1]}.`)
    else
      @println(`Task ${argv[1]} is already complete.`)
    end

    return
  end

  usage()
end

try run() catch err
  case is fs.FsError.OperationFailed
    @eprintln(`tasks: cannot ${err.operation} '${err.path}': ${err.message}`)
    @exit(1)
  case is TaskError.InvalidData
    @eprintln(`tasks: invalid data on line ${err.line}; expected [ ] title or [x] title`)
    @exit(1)
  case is TaskError.InvalidTitle
    @eprintln("tasks: title must be nonempty and on one line")
    @exit(2)
  case is TaskError.InvalidNumber
    @eprintln(`tasks: '${err.input}' is not an existing task number; use list --all`)
    @exit(2)
  case _
    @eprintln("tasks: unexpected error")
    @exit(1)
end

Try it yourself

Try done 0, done many, and done 999. Check that all three fail without changing your tasks.

Expected result

Each command reports that the supplied value is not an existing task number. list --all still shows the same four tasks and completion states. The current script launcher adds its own failure message; the compiled program will return the usage status directly.

Your tool now has all three core operations: list, add, and complete. Next, we'll make its logic easier to test and reuse.

Read more: text parsing, error handling.

Previous6. Add tasksNext8. Organize and test