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

Read saved tasks

Load a text file and handle failures with typed errors.

Chapter 5 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 4's command-aware program. We'll keep the commands and display functions, but replace the embedded sample list with tasks read from a file.

Give the data a home

Create tasks.txt beside tasks.tea with this content:

tasks.txt

[ ] Learn Tea
[ ] Build a CLI
[x] Take a break

Each line starts with [ ] for an open task or [x] for a completed task, followed by its title. Numbers are positions in the file, starting at 1; we don't need to store them separately.

Run commands from your project directory. tasks.txt is relative to the directory where you run the command, not the location of the Tea source or executable. This lets different directories have separate task lists.

Import file and string helpers

Add use fs from "std.fs" and use string from "std.string" beside the existing import. In the list branch of run, replace the embedded task list with:

const tasks = load_tasks("tasks.txt")

We'll add the functions it needs before run. Start with a domain error:

error TaskError {
  InvalidData(line: Int)
}

An error can have named variants and carry useful information. Here the line number lets us explain where a saved file is malformed.

Turn text into tasks

Add valid_title and decode_tasks from the complete checkpoint. valid_title rejects titles containing only spaces or tabs, and titles containing a newline or carriage return. It preserves other text, including punctuation and Unicode.

decode_tasks builds an initially empty List[Task]. An empty list doesn't provide an element type to infer, so we annotate it explicitly. @append(tasks, value) adds a task to the list.

The decoder normalizes Windows line endings, splits the content into lines, and checks each line's prefix. line[4..@len(line)] takes the title after the four-character marker. The .. range excludes its endpoint. A malformed line triggers throw TaskError.InvalidData(number) rather than silently disappearing from the list.

Make failures part of the signature

Add this loading function:

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

  decode_tasks(fs.read_file(file))
end

The ! after the return type declares errors the function can raise. The braces list two possible error types: file operations can fail with fs.FsError, and decoding can fail with TaskError.

Tea propagates those errors through the calls in this function. The same error set must be declared on run, because it calls load_tasks. A missing file is treated as an empty list; other filesystem failures propagate.

Replace the final run() call with the try run() catch err block in the complete checkpoint. Its case is arms handle each error variant. A filesystem error carries an operation, path, and message; our data error carries a line number. Both go to standard error and end the program with @exit(1).

Read the saved list

tea tasks.tea list --all
1. [ ] Learn Tea
2. [ ] Build a CLI
3. [x] Take a break

Change Learn Tea to another title in tasks.txt and run again. You should see the changed title without editing tasks.tea. Restore the sample title before continuing so your output matches the next chapters.

Complete checkpoint

Keep the tasks.txt file you just created. Replace tasks.tea with this version if you need a fresh starting point:

Complete tasks.tea for this chapter

tasks.tea

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

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)
}

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 help() -> Void
  @println("Usage: tasks [list [--all] | --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

  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 _
    @eprintln("tasks: unexpected error")
    @exit(1)
end

Try it yourself

Temporarily remove the marker from the second line of tasks.txt. Run the program and inspect the error, then restore the file.

Expected result

The program reports tasks: invalid data on line 2; expected [ ] title or [x] title. It doesn't print a partial list or change the file. Tea's launcher may add its own unsuccessful-exit message.

You can now turn external data into typed values and explain a failure to your user. Next, we'll write updated values back to disk.

Read more: typed errors, filesystem operations, string helpers.

Previous4. Accept commandsNext6. Add tasks