4  Reproducible Development Environments and Pipelines with T

4.1 Introduction

Now that we have Nix installed, and our IDE configured, we can actually tackle the reproducibility puzzle. This chapter will introduce T, a domain-specific language developed to build reproducible analytical pipelines using Python, R or Julia.

Before anything, I think it’s important to see what an actual T pipeline looks like, to ground the rest of our discussion. Here is an example (you can find this example, and many others, in the T Demos Repository1):

p = pipeline {
  -- 1. Read gorilla image
  gorilla_pixels = pyn(
    command = <{
from PIL import Image
import numpy
def read_image(x):
    im = Image.open(x).convert("L")
    pixels = numpy.asarray(im)
    return pixels

gorilla_pixels = read_image("path/to/gorilla-waving-cartoon.jpg")
    }>,
    include = ["data/gorilla"]
  )

  -- 2. Threshold level
  threshold_level = pyn(command = <{ threshold_level = 50 }>)

  -- 3. Compute coordinates
  py_coords = pyn(
    command = <{
import numpy
py_coords = numpy.column_stack(numpy.where(gorilla_pixels < threshold_level))
    }>
  )

  -- 4. Convert to DataFrame for R (transfer via Arrow)
  raw_coords = pyn(
    command = <{
import pandas as pd
raw_coords = pd.DataFrame(py_coords, columns=["V1", "V2"])
    }>,
    serializer = ^ipc
  )

  -- 5. Clean coordinates in R
  coords = rn(
    command = <{
library(dplyr)
coords <- clean_coords(raw_coords)
    }>,
    functions = ["src/functions.R"],
    deserializer = ^ipc,
    serializer = ^ipc
  )

  -- 6. Gender distribution
  gender_dist = rn(
    command = <{
library(dplyr)
gender_dist <- gender_distribution(coords)
    }>,
    functions = ["src/functions.R"],
    deserializer = ^ipc
  )

  -- 7. Plots
  plot1 = rn(
    command = <{
library(dplyr)
library(ggplot2)
plot1 <- make_plot1(coords)
    }>,
    functions = ["src/functions.R"],
    deserializer = ^ipc
  )

  plot2 = rn(
    command = <{
library(dplyr)
library(ggplot2)
plot2 <- make_plot2(coords)
    }>,
    functions = ["src/functions.R"],
    deserializer = ^ipc
  )

  -- Render Quarto report
  report = node(script = "src/report.qmd", runtime = Quarto)
}

-- Materialize
populate_pipeline(p, build = true, verbose=1)
T execution failed:
Error running t (error code 1): <no output>

You do not need to understand every line yet, but let’s walk through it together, because this single example contains the whole idea.

The big picture. p = pipeline { ... } defines a pipeline: a directed graph of named computation steps called nodes. Every name inside the braces is a node, and p itself is a first-class value you can inspect, transform, and pass around. Nothing runs yet. The code in the braces is a description. The last line, populate_pipeline(p, build = true, verbose = 1), is what actually executes the pipeline: it materializes the graph and builds every node in dependency order, printing each node’s output as it goes.

The nodes. Each node is a computation in a specific runtime:

  • pyn(...) declares a Python node, rn(...) an R node, and node(script = ..., runtime = Quarto) a Quarto node that renders the final report.
  • The code inside <{ ... }> is the node’s body. T stores it verbatim; it does not parse or execute it itself. When the node is built, the code runs inside a hermetic Nix sandbox provisioned for that runtime.
  • gorilla_pixels opens the image and converts it to a grayscale array; include = ["data/gorilla"] copies the raw image into the sandbox so the code can find it.
  • threshold_level is a node that produces a single number. It looks trivial, but that is the point: because the threshold is a node, it is part of the graph. Change it, and only the downstream nodes re-run.
  • The rest follows the data: py_coords locates the dark pixels, raw_coords turns them into a pandas DataFrame, coords cleans them in R, and gender_dist, plot1, and plot2 produce the final table and plots.

How the nodes are wired together. Notice that no node says “I depend on X”. T infers the dependencies by scanning each node’s code for names: py_coords’s body mentions gorilla_pixels and threshold_level, so T draws edges from those nodes into py_coords; coords mentions raw_coords, and so on. You can declare the nodes in any order and T assembles the graph, checks it for cycles, and builds in the right order.

Crossing the language boundary. The interesting part of this analysis is that data moves from Python to R. That is what serializer and deserializer are for. serializer = ^ipc tells T to write the node’s output as an Arrow IPC file, a columnar format that both pandas and R’s {arrow} package read natively, so the data crosses the language boundary without a lossy CSV round-trip. The R node’s deserializer = ^ipc reads it back. And functions = ["src/functions.R"] sources a file of R helpers (clean_coords(), make_plot1(), …) into the sandbox before the node’s command runs.

Why this is reproducible. Every node runs in its own Nix sandbox, with exactly the packages declared for the project, so no machine-specific state can leak in. And every node’s output is cached by content: re-run the pipeline and T rebuilds only the nodes whose inputs actually changed. The whole analysis (image, threshold, cleaning, plots, report) is now a single machine-readable description that anyone can execute from raw inputs to final outputs.

We will come back to each of these pieces: T’s syntax in the next section, the project’s environment in the rest of this chapter, and building your first pipeline in Chapter 5. For now, hold onto the core idea: a pipeline is a named graph of computations, and the dependencies are the names you use.

Let’s go back to some theory before moving on.

4.1.1 The Reproducibility Puzzle

Reproducibility in research and data science exists on a continuum. At one end, authors might only describe their methods in prose. Moving along the spectrum, they might share code, then data, and finally what we call a computational environment: the complete set of software required to execute an analysis.

Even when researchers share code and data, they rarely specify the full software stack: the exact version of R, all package versions, and crucially, the system-level dependencies. Yet differences in any of these can lead to divergent results from the same code.

The stakes are well documented. Large-scale replication studies have consistently found that a substantial fraction of published results cannot be reproduced Brodeur et al. (2026), and reproducibility has become a first-class concern for computational science (Stodden et al. 2016).

Tools like {renv} address part of the puzzle: they capture R package versions in a lockfile. But {renv} does not manage the R version itself (you need rig for that), and neither handles system libraries. If {sf} requires GDAL 3.0 but your system has 2.4, {renv} can’t help. And if your project uses both R and Python? Now you’re coordinating multiple package managers, each with its own configuration.

4.1.2 Reproducibility Is Not Just About the Environment

Here is a subtlety that most reproducibility tools miss: reproducing the environment is necessary but not sufficient. The code also has to run.

Consider what typically happens. A researcher shares a renv.lock, or even a Nix expression pinning every dependency to the exact version used. A colleague clones the repository, reconstructs the environment perfectly, and… opens the project to find a folder full of scripts with no obvious order of execution. Which script comes first? Which outputs feed into which inputs? Are there manual steps in between? The README might answer some of these questions, or it might not. Reproducing the environment got them 80% of the way there, but that last 20% is still a puzzle they have to solve by reading someone else’s code.

This is the difference between environment reproducibility and computational reproducibility. The former means the same packages are available. The latter means the same computation can be re-executed, from raw inputs to final outputs, by anyone with access to the code, without prior knowledge of the project.

Computational reproducibility requires something the environment alone cannot provide: an explicit description of the pipeline. Not prose in a README. Not conventions like “always run 01-clean.R before 02-model.R”. An actual, machine-readable graph of computations with explicit dependencies, where running one command re-executes the whole analysis in the correct order.

4.1.3 The Mindset Shift: From Tasks to Rules

There is a deeper point lurking here, one that goes beyond the technical details of package managers and lockfiles.

Think about two data scientists on the same team. Both write R or Python. Both know their tools. But they have fundamentally different mental models.

The first thinks in tasks: I need to clean this dataset. I need to fit this model. I need to produce this report. He writes a script for each task, runs them manually in the right order, and the analysis is done. If someone else needs to reproduce it, our friend shares the scripts and a README and hopes for the best.

The second thinks in rules: What are the general rules that govern this analysis? What are the inputs? What are the outputs? What depends on what? If the data changes, which steps need to re-run? If a colleague on a different machine runs this, will he get the same result? He writes code that describes the system, not just the individual actions.

This is not a question of technical sophistication. It is a question of mental model. The first person sees the computer as a place where tasks happen. The second sees the computer as a machine that can be taught to follow rules repeatably, consistently, and at scale.

The shift matters enormously for reproducibility. If your analysis is a sequence of manual tasks, it is inherently fragile: it depends on you remembering the order, running the right scripts, and not making any ad hoc changes along the way. If your analysis is a system of explicit rules (a formal description of what produces what), then it can be re-executed by anyone (including AI agents), on any machine, at any point in the future, with a single command.

This framing is not unique to data science. Institutional economics argues that the rules of the game (formal and informal) shape collective outcomes more than individual talent (North 1990). Behavioural economics shows that the design of the choice environment steers behaviour as much as persuasion does (Thaler and Sunstein 2008). Legal scholars have noted that code itself regulates, because constraints written in software are harder to ignore than rules written in prose (Lessig 2006). And design research has catalogued how the affordances of everyday tools silently determine what their users can and cannot do (Norman 2013).

A pipeline is the same idea applied to an analysis: the institutional design of the computation, a choice architecture that makes the reproducible path the path of least resistance.

This is the insight that motivates the pipeline-first approach in this book. Before we even talk about packages and environments, we are asking: what is the structure of this computation? Making that structure explicit, formal, and machine-readable is the only approach that delivers genuine computational reproducibility, and, as we will see, it is also the approach that works best with AI-assisted development.

4.1.4 Thinking in Systems in an AI-First World

If the previous section sounded abstract, consider what is happening right now to the practice of data science.

AI coding assistants have become genuinely capable. For many routine tasks such as data cleaning, visualisation, standard modelling, an LLM can write solid R or Python code faster than most humans. The cost of writing code is collapsing. This means the skill of typing correct syntax is becoming less valuable, and the skill of knowing what to ask for is becoming more valuable.

But here is the catch: knowing what to ask for requires domain knowledge that no LLM reliably has, or rather, doesn’t reliably surface to you. Take a simple example. You are working with time series data on prices. Ask an LLM to clean and graph the data, and it will almost certainly produce competent code. But will it suggest deflating nominal prices to make them comparable across time? Maybe, maybe not. That depends on whether the LLM has encountered enough economic methodology in its training data, and whether you thought to ask. And crucially, will you recognise when the LLM’s answer is subtly wrong? You don’t know what you don’t know. And neither does the LLM, but, unlike you, it won’t flag its own blind spots… or perhaps it will, depending on its training. But you can’t rely on LLMs perhaps telling you what you need to pay attention to.

This points to a skill that is going to matter more, not less, as AI matures: the ability to interrogate and challenge an LLM’s output. An AI assistant is confident by default. It will produce plausible-sounding analysis regardless of whether the underlying reasoning is sound, and even when it does provide some points you need to pay attention to: how can you be sure those are the right things to pay attention to? The person who gets value from it is not the one who accepts the first answer, but the one who can ask “wait, did you account for inflation here?”, or “why did you choose this model over that one?”, or “what assumptions are you making about the data generating process?”. That requires knowing enough to ask those questions.

This has an interesting implication for how we should think about expertise. The traditional advice has been to specialize: know one language deeply, one domain deeply, one tool deeply. That advice was right when expertise was expensive to acquire and hard to substitute. But in a world where an LLM can write the implementation for you, having some knowledge across many areas may become more valuable than deep specialisation in one. A data scientist who knows a little economics, a little software engineering, a little domain-specific methodology, and can draw on all of it to design and audit what the LLM produces, may outperform a narrower specialist who can no longer leverage their deep implementation skills because the LLM already has them.

I will close this section with a personal anecdote that I think illustrates the point well. Alongside this book, I have been building a Game Boy game using LLMs to write essentially all the code. I know nothing about Game Boy development: I do not know the hardware architecture, I have never written a line of assembly, and I have certainly never shipped a cartridge. But I approached the project exactly as I would approach a data science project.

The first thing I did was set up a reproducible development environment. The second was to make the LLM build tooling to query the game’s state programmatically: not just run the game, but interrogate it. What is the player’s position? What are the active sprites? What is the current game phase? With that infrastructure in place, I could instruct the LLM to run test scenarios from any point in the game, with any parameters, and get structured feedback. The game could be developed and debugged in a principled loop rather than by manually playing through it each time.

The result: an actual, running Game Boy game (well, more of a demo really, but still), built by someone who cannot write the underlying code. What I contributed was the systems thinking: insisting on reproducibility, insisting on programmable state inspection, insisting on a feedback loop that could be automated. The LLM contributed the implementation. Neither of us could have done it alone.

This is the approach that this book is trying to teach for data science. The specific tools matter less than the underlying mental model: make the structure of your computation explicit, build in the ability to inspect and query it at every stage, and create feedback loops that you and your AI collaborators can iterate on together.

4.1.5 The Polyglot Challenge

Modern data science is increasingly polyglot. Research shows that data scientists use, on average, nearly two programming languages in their work, with R and Python being the most common combination (see Chen et al. 2025). Python dominates machine learning, R excels at statistical modelling, and Julia offers high-performance numerics. Projects increasingly combine these strengths.

This creates a reproducibility challenge: a project using R, Python, and Quarto requires coordinating multiple package managers. Nix solves this by providing a unified framework for all languages and system tools. But there is still a missing piece: something to orchestrate all these moving parts within a single, coherent and reproducible workflow.

4.1.6 Enter T

However, Nix alone has a steep learning curve. Its functional programming language can be daunting for researchers focused on their analysis, not system administration.

I know this problem well. My first attempt at solving it was {rix}: an R package that generates Nix expressions from intuitive R function calls. You describe what you want, and {rix} figures out how to express it in Nix. I then built {rixpress} on top of it, an R package for defining reproducible, polyglot analytical pipelines. Both packages solved real problems, but they were fundamentally R-focused. {rix} needed R to run, and the pipeline syntax of {rixpress} was R code. If you worked in Python or Julia primarily, you were a second-class citizen.

T is what I built next, and it goes far beyond both. T is a reproducibility-first domain-specific language (DSL) for polyglot data science. It is not an R package. It is its own language, with its own runtime, its own REPL, and its own ecosystem of packages, built from the ground up to treat R, Python, Julia, and Shell as equals. T is meant to orchestrate R, Python and Julia, but critically, T does not just orchestrate environments defined elsewhere: it is the environment definition system. Every T project is a Nix flake, and T manages the entire stack: language runtimes, system dependencies, and pipeline execution under one roof.

T provides a functional, immutable language for constructing composable micropipelines: first-class, introspectable computation graphs that coordinate R, Python, Julia, Quarto, and Shell execution within a unified system. Pipelines in T are not configuration artifacts but executable program structures with explicit dataflow, typed nodes, and content-addressed outputs. And unlike any other language used for data science, where reproducibility is more often than not bolted on, T makes reproducibility impossible to opt out of: every node in a T pipeline runs in its own hermetic Nix sandbox, and every output is content-addressed by design. As far as I know, T is literally the first reproducibility by design programming language.

There is a broader current here. The history of computing is a history of raising the level of abstraction: from telling the CPU exactly what to do, to describing data transformations, to, with AI, describing the system, its constraints, and your intent. We are rediscovering that the oldest Unix philosophy was right all along: everything should have an explicit, textual representation that can be composed into larger systems. And once the design of your project is explicit, whether the implementation is R, Python, or Julia becomes almost an implementation detail. That is the main design principle behind T.

And here is an important reassurance if you already live in R, Python, or Julia: you do not have to use T’s own built-in functions at all. T is a superset, not a replacement. You can write every node’s logic in the language you already know and let T do exactly what it is best at: orchestrating those nodes into a reproducible pipeline. T’s data-manipulation verbs and other builtins are there when you want them, but for pure orchestration they are entirely optional.

A crucial point to internalise: the primary way to work with T is to write Pipelines. T is not, first and foremost, a scripting language. If you write a plain script full of commands and try to execute it with t run, it will not run at all: non-interactive execution requires a pipeline. You can override this with the --unsafe flag (t run --unsafe script.t), but that is really not recommended. Think of it as a workaround for little helper scripts, for example on CI.

That said, T does come with a REPL. In the REPL you can run commands interactively, exactly the way you would in R or Python. Later in the book (Chapter 14), we will see how T’s own data-manipulation verbs let you wrangle data, and you can do all of that right inside the REPL.

T is also designed to treat pair-programming with LLMs, or even delegating the entire pipeline-writing process to an LLM, as a first-class programming model. To support this, T provides built-in mechanisms for LLMs to introspect and query build artifacts with ease. Each T project also includes a dedicated AGENTS.md file that guides the LLM on how to properly write and work with T pipelines. Pair programming with LLMs will be studied later, though. A plain-text, explicit pipeline is what makes this work: it is far easier for a model to read, write, and verify a diffable description of the computation than to reason about the hidden state of an interactive notebook (see Chapter 3).

The workflow is simple:

  1. Bootstrap a T project using t init
  2. Declare your dependencies in tproject.toml
  3. Define your R, Python or Julia functions to perform the actual analysis
  4. Write (or better yet, have your AI agent write for you) your reproducible pipeline as a T pipeline in src/pipeline.t
  5. Build and run with t run

4.1.7 A candid note: who this is for (and who it isn’t)

Before we get hands-on, let me be straight with you about where T stands.

T is young. It is new, still in beta, and honestly niche. Its API and some of its behaviour will keep changing as it matures. If you are looking for a battle-worn tool with a decade of production scars and a huge community, T is not it yet.

I built it for myself first. I designed T to solve my own problems: the polyglot reproducibility pain I kept hitting in my own work, where R, Python and Julia all had to coexist in one reproducible environment. That is also why I am so motivated to keep building it: it is the tool I wish I had had, and I use it every day. This book is, in a sense, the documentation I wanted to exist. I am writing it for myself first, and making it available in the hope that you find it useful too.

You do not have to switch. If {targets} (Landau 2021) (for pure-R work) or Snakemake (Köster and Rahmann 2012) (for polyglot work) already serve you well, that is a perfectly good choice. Both are robust, well documented, and backed by large communities. The core ideas in this book (Nix-managed environments, an explicit pipeline, and testing) apply no matter which orchestrator you pick. T is an option, not a requirement, but one I would urge you to consider, as I believe it tackles the problem of reproducibility correctly, thanks to being built on top of Nix.

What T adds is that the environment and the pipeline are the same language: one file defines your exact, bit-for-bit reproducible stack and the computation that runs on it, and the pipeline is a first-class value you can inspect and transform. It is also designed to be written and verified by LLMs. Here is a fair comparison:

T targets Snakemake rix / rixpress
Languages R, Python, Julia, Shell R (mostly) Python rules + any via envs R (Python via ryxpress)
Environment management Yes: Nix-based, env + pipeline in one No: pair with {renv} Partial: conda envs Yes: Nix-based
Pipeline as first-class data Yes: query, filter, transform, compose Limited Limited Limited
Maturity / community Young, in beta, small Very mature, large Very mature, large Moderate
Learning curve Low for R users (new DSL) Low for R users Moderate (Snakefile) Moderate
Designed for LLMs Yes No No No

So read this book as one person’s answer to the reproducibility puzzle. Take what helps; and if T itself is not for you, the Nix and pipeline ideas still are.

This chapter covers everything you need to know to create project-specific, reproducible development environments and pipelines for your polyglot data science projects. The next section focuses on T, and its syntax; but I want to be very clear. You do not need to write your analyses in T! You can continue working with R, Python or Julia, and only use T for orchestration. But T also has some interesting features as a language, and you might want to take a look at its syntax and capabilities.

4.2 A Quick Tour of T Syntax

Before we build our first pipeline, here is a fast tour of T’s syntax. T is a small, functional language, so the surface area is modest. You do not need to memorise any of this now, treat it as a map you can return to as the chapters unfold.

4.2.1 Variables and assignment

T binds variables with =. Values are immutable by default; to rebind an existing name (shadowing), use :=:

x = 10
name = "Alice"
x := 20    -- rebinds x to 20

4.2.2 Conditionals

Conditionals are expressions. They return values:

result = if (x > 5) "high" else "low"

4.2.3 The two pipes

T has two pipe operators, and the difference between them is one of the language’s defining ideas: errors are values.

  • |> passes the left-hand value to the right-hand function and short-circuits on error: if the left-hand value is an Error, the pipeline stops and the error is returned without calling the function.
  • ?|> (the maybe-pipe) always forwards the left-hand value to the right-hand function, even if it is an Error. This is how you write explicit recovery.
[1, 2, 3] |> map(\(v) v * v) |> sum -- 14
error("boom") |> double             -- Error: short-circuits

handle = \(x) if (is_error(x)) "recovered" else x
error("boom") ?|> handle                  -- "recovered"

double() will not even run, since |> short-circuits. However, in the second example, handle() will get the error object passed to it because of ?|> and will do something with it (whatever you programmed handle() to do with errors).

4.2.4 Imports

If you create a T package, you can import it in a pipeline, or import specific functions from it. You can also import functions defined in plain scripts:

import mypackage
import mypackage [fn1, fn2]
import "helpers.t" [clean_data, normalize]

Note that to use R, Python or Julia packages, the import statements of each respective language need to be written inside the required <{...}> foreign code blocks, as we shall see below.

4.2.5 Sequence generation

Inspired by R:

seq(5)                -- [1, 2, 3, 4, 5]
seq(1, 10, by = 2)    -- [1, 3, 5, 7, 9]

4.2.6 Operators at a glance

The tour so far has met operators one at a time. Here is the whole set in one place, listed in order of precedence (loosest first), so a pipe chain reads left to right without parentheses, and 1 + 2 * 3 still means 1 + (2 * 3).

Operator Job Notes
=, := assign, rebind := shadows an existing name
if (c) a else b conditional expression always returns a value
\|>, ?\|> pipe, maybe-pipe \|> short-circuits on Error; ?\|> forwards it
~ formula y ~ x; the language of models and case_when
\|\| scalar OR both sides must be Bool
&& scalar AND both sides must be Bool
\| scalar bitwise OR scalar operands only
.\| element-wise OR Lists, Vectors, NDArrays
& scalar bitwise AND scalar operands only
.& element-wise AND Lists, Vectors, NDArrays
==, !=, <, >, <=, >= scalar comparison == on a collection is a TypeError
in membership x in [1, 2, 3]; the right side is a list
%name% custom infix operator R-style; packages define them, you consume them
.==, .!=, .<, .>, .<=, .>= element-wise comparison 1 .== [1, 2] is [true, false]
+, - scalar add, subtract int + float promotes to float
.+, .- element-wise add, subtract the workhorses of column math
*, /, % scalar multiply, divide, modulo
.*, ./, .% element-wise multiply, divide, modulo
., () field access, function call tightest of all

Unary operators sit above everything in the table: - (negation), ! (logical not), and !! / !!! (unquote and unquote-splice, the metaprogramming tools from Chapter 15, heavily inspired by R’s {rlang}).

Two distinctions matter more than any single operator. First, the scalar-versus-broadcast split: the plain operators (+, ==, &, …) demand scalar operands and say so with a TypeError, while the dotted operators (.+, .==, .&, …) do the work element-wise across Lists, Vectors, and NDArrays. No silent coercion, no surprises. Second, the pipes bind loosest of all, which is what lets you write long chains without parenthesising anything.

4.2.7 What can a T value be?

Every expression in T evaluates to a value, and the set of values is small enough to hold in your head:

Value Example Where it is covered
Int, Float, Bool, String 42, 3.14, true, "hi" everywhere
NA NA missing data, Chapter 14
Error error("boom") errors as values, Chapter 6
Symbol a bare name captured as data non-standard evaluation, Chapter 15
List / Dict [1, 2, 3], [name: "Alice"] collections, Chapter 15
Vector a typed numeric or text vector columns, Chapter 14
NDArray an n-dimensional array Chapter 14
DataFrame read_csv("data.csv") Chapter 14
Factor a categorical column Chapter 14
Date / Datetime ymd("2024-01-01") Chapter 14
Formula mpg ~ wt models, Chapter 14
Lens get(mtcars, col_lens("mpg")) Chapter 15
Lambda \(x) x + 1 functions, Chapter 15
Pipeline / Node pipeline { ... } Chapters 5 and 8
Intent intent { ... } Chapter 15

Most of the rest of this book is a tour of this table, one row at a time.

4.2.8 How T evaluates

Three ideas make T’s behaviour predictable once you see them.

The data mask. Inside a verb, column names resolve against the data the verb is operating on. That is why df |> filter($age > 30) works even though age is not a variable in scope: filter masks the columns of df into the expression, and $age picks one out. You write expressions over columns, not over R or Python objects.

Non-standard evaluation. Some verbs need the expression, not its value. mutate($bonus = $salary * 0.1) captures the right-hand side and evaluates it row-wise later; a bare name in that position is captured as a symbol, not looked up. The explicit tools for this (quo, !!, !!!) are discussed in Chapter 15; the verbs in Chapter 14 use it under the hood.

Errors as values. A failing expression does not crash the program; it produces an Error value you can test with is_error, branch on with if or match, or route with ?|>. That is what makes the two pipes possible, and it is why a long pipeline can degrade gracefully instead of dying at the first bad row.

4.2.9 Types, briefly

You will meet annotations like \(x: Int, y: Int -> Int) in later chapters. T has a light but real type system: parameter and return annotations, generic type variables, a strict mode that requires full signatures on top-level functions, and semantic types like DataFrame[schema]. We take it apart in Chapter 15; for now, read an annotation as a promise about what a function accepts and returns.

T’s functions (how to define them, closures, and higher-order functions) are a topic in their own right, and we take them up properly in Chapter 15.

4.3 Transitioning to Nix-Managed R

Now that Nix is installed, I strongly recommend uninstalling any system-wide R installation and removing the packages in your user library (typically found in ~/R on Linux or ~/Library/R on macOS). From this point forward, let T and Nix handle everything. If you are using Windows, you can keep your Windows-specific R installation, since Nix will not interfere with it (remember, Nix is installed inside WSL and Positron will automatically load Nix environments installed in WSL as well).

If you are not ready to take this step, you can still use T: it manages its own Nix-sandboxed R environment per project node, so it will not interfere with any existing R installation. However, for the cleanest experience and to avoid potential subtle conflicts, I recommend fully committing to Nix.

4.3.1 Bootstrapping T without a local installation

T is distributed exclusively via Nix. You don’t need to install it in the traditional sense. Instead, you launch a temporary shell that provides the t executable, use it to scaffold a new project, and then let the project itself manage the T version it uses, which is pinned in the project’s flake.lock.

Running the following line in a terminal will drop you into an ephemeral shell with t available:

nix shell --accept-flake-config github:b-rodrigues/tlang

This gives you a temporary shell with t ready to use. From here, you can scaffold any new project. For example, navigate to your projects directory and initialize a new project:

t init --project my-analysis

This creates a new directory my-analysis/ containing the necessary project files. When prompted, you will be asked for basic project information and an AI Agent Context Level (Small, Medium, Full, or Huge), which generates tailored reference documentation for LLMs. More on this in a later chapter.

You can then leave the temporary shell and enter the project’s own reproducible environment:

exit
cd my-analysis
nix develop

That last command, nix develop, is going to be very important. It is the command that enters the project’s development shell, which provides the pinned version of t alongside all declared R, Python, and Julia runtimes. All subsequent commands should be run inside this shell. To leave that shell, type exit just like we did above to leave the temporary shell to bootstrap our project.

TipGetting LLM assistance with T

As already mentioned, T is designed from the ground up for AI-assisted development. When you initialize a new T project, two files are automatically generated in the project root:

  • AGENTS.md: A project-specific onboarding guide that tells LLMs how to work within your project’s architecture.
  • T-LANGUAGE-REFERENCE.md: A tiered technical reference for the AI to read, tuned to the context level you chose at t init.

With these files, any AI agent you pair-program with has immediate access to the exact technical context it needs to write correct T pipelines. You can also supply additional context by pointing your LLM at the T documentation website2.

4.4 The T Project

Every T project has the following directory structure:

my-analysis/
├── tproject.toml       # Project configuration and dependencies
├── flake.nix           # Reproducible environment definition
├── flake.lock          # Locked dependency versions
├── README.md           # Project overview
├── AGENTS.md           # Onboarding guide for AI Agents
├── T-LANGUAGE-REFERENCE.md # Tiered language reference for LLMs
├── src/
│   └── pipeline.t      # Your main analysis script
├── data/               # Place your raw data files here
├── outputs/            # Output directory for results
└── tests/              # Unit tests for your analysis

The two most important files are tproject.toml and src/pipeline.t.

4.4.1 Declaring Dependencies with tproject.toml

T projects are explicit: R, Python, and Julia packages belong in tproject.toml, not in ad-hoc install.packages(), pip install, or Pkg.add() calls. Open tproject.toml and add the runtime packages you need:

[r-dependencies]
packages = ["dplyr", "ggplot2"]

[py-dependencies]
version = "python313"
packages = ["polars", "scikit-learn"]

[jl-dependencies]
version = "lts"
packages = ["DataFrames", "CSV", "GLM"]

For Julia, the version field defaults to "lts" (the current Julia long-term-support release). To pin a specific Julia version, use formats like "1.10" or "1.11", which map to julia_1_10 or julia_1_11 in Nixpkgs.

After editing tproject.toml, sync the project environment:

t update

You might get the following error:

Error: Git working directory is not clean. Commit or stash changes first.

This is because each T project also gets automatically tracked by Git. I won’t talk about how to set up Git, but this is another way that T is a reproducibility-first language: the projects must be version controlled! Running t update will generate the flake.nix and flake.lock files. These files are what define the computational environment your pipeline will run in. These also need to be tracked by Git. You will get this error again:

bash-5.3$ t update

No T package dependencies declared in tproject.toml; project defines 3 R
dependencies, 4 Python dependencies and 2 additional tools.

Syncing 0 T dependencies, 3 R dependencies, 4 Python dependencies, 0 Julia
dependencies and 2 additional tools from tproject.toml → flake.nix... Running
nix flake update... Error: Failed to update dependencies: Command 'nix flake
update --accept-flake-config' failed (exit 1): error: Path 'basic_t/flake.nix'
in the repository "/home/user/projects/project-1" is not tracked by Git.

       To make it visible to Nix, run:

       git -C "/home/user/projects/project-1" add "flake.nix"

Follow the instructions and then run t update again. This is a one-time setup, and if everything goes well, you should see this:

Running nix flake update...
flake.lock changes:
  - flake.lock created.
Updated.

Then exit the temporary shell, and re-enter the development shell so the updated package set is active:

exit
nix develop

4.4.1.1 Why NOT to use install.packages() / pip install / Pkg.add()

It’s crucial to understand: never call imperative installation commands from within a T project. Here’s why:

  1. Technically, it won’t work: Computations defined in your pipeline run inside hermetic Nix sandboxes. Anything not declared through Nix simply will not be available during pipeline execution.
  2. Conceptually, it defeats reproducibility: The whole point of T’s Nix integration is that your environment is fully defined by tproject.toml and flake.lock. Ad-hoc installations break this guarantee.

Instead, add packages to tproject.toml and run t update. Calling install.packages() (or equivalent commands for Python and Julia) will raise an error.

4.4.2 Working with R, Python, and Julia

Declaring dependencies in tproject.toml is not just bookkeeping: it gives you working R, Python, and Julia environments. Once you are inside the development shell, typing R, python, or julia starts the corresponding interpreter, with every package you declared already installed.

This makes the shell a natural place to do interactive work. You can develop small functions in R, Python, or Julia, test them, and then have your LLM agent wire them into the T pipeline. A setup that works well: start an editor such as Positron from inside the shell (for example, by typing positron), and work on your functions there while a terminal sits next to it, where your LLM agent builds the pipeline incrementally, node by node. Because the editor inherits the shell’s environment, its R, Python, and Julia sessions use exactly the same versions and packages as your pipeline.

4.4.3 Alternative installation methods

T provides several other ways to install packages, using uv for Python or {renv} for R. By default, T uses Nix to resolve packages directly. We recommend sticking to the default resolver as much as possible. The default approach works exceptionally well for Julia and R because virtually all packages in their ecosystems are available through Nix. This is not the case for Python.

Python’s PyPI contains hundreds of thousands of packages of varying quality. If you use Python for data analysis, you’ve likely hit this issue: you try to install one package that requires numpy < 2, and another that requires numpy >= 2. You’re cooked, as the youths say. The resolver can’t help you because the requirements are literally incompatible. No amount of Rust-written package managers can solve this. The underlying issue is PyPI’s ecosystem model.

In R, this situation rarely happens. CRAN enforces a system where packages are continuously tested against their reverse dependencies. If {ggplot2} or {dplyr} updates in a way that breaks other packages, CRAN catches it. Authors have two weeks to fix the issue, or their package gets archived. As a result, if a package is on CRAN, it works. CRAN manages this consistency across ~27,000 packages.

PyPI does not do this. It hosts packages without global consistency checks. If Package A and Package B declare mutually exclusive requirements, PyPI hosts them both anyway. Nix tries to curate Python packages, but because of PyPI’s constraints, the entirety of PyPI cannot be made available through Nix in an automated way. Occasionally, you can patch a package’s pyproject.toml in Nix to relax incompatible constraints (e.g., changing numpy = "^1" to numpy = ">=1"), but patching isn’t a universal solution.

This is why T provides alternative resolvers (uv for Python and {renv} for R): * For Python (uv): Allows Nix to build the environment while fetching packages directly from PyPI via uv. This is especially useful if you are collaborating with non-T/Nix users who rely on standard pyproject.toml and uv.lock files. * For R (renv): Allows you to reuse existing renv.lock files for collaboration or legacy workflows.

4.4.3.1 Alternative Python Resolver: uv

Instead of declaring Python packages directly in tproject.toml, you can delegate dependency management to uv.

First, configure tproject.toml:

[py-dependencies]
resolver = "uv"
workspace = "python"

The workspace directory must contain your uv project metadata and lock file:

python/
  pyproject.toml
  uv.lock

The version field in tproject.toml is optional when using the uv resolver. If omitted, T infers the Nixpkgs Python attribute (e.g., python312) from the requires-python field in python/pyproject.toml.

The inference accepts specifiers that constrain to a single minor version (==3.12, ==3.12.*, ~=3.12, >=3.12,<3.13) and errors on open-ended ranges (>=3.12). If an explicit version conflicts with requires-python, T prints a warning and uses the explicit version.

When using resolver = "uv", do not set [py-dependencies].packages. Python dependencies are declared exclusively in pyproject.toml and locked by uv.lock. Running t update generates uv2nix/pyproject.nix inputs in flake.nix and builds the Python environment as a Nix virtual environment.

If you don’t have uv installed locally and lack existing metadata:

  1. Edit tproject.toml to set the resolver:

    [py-dependencies]
    resolver = "uv"
    workspace = "python"
  2. Create a minimal pyproject.toml inside the python/ directory:

    # python/pyproject.toml
    [project]
    name = "my_project_python_env"
    version = "0.1.0"
    requires-python = ">=3.12,<3.13"
    dependencies = [
        "pandas",
    ]
  3. Enter a temporary Nix shell to generate the lock file using uv:

    nix shell nixpkgs#uv nixpkgs#python3
    uv lock --project python
    exit
  4. Re-enter your T development shell and update:

    nix develop
    t update

This reads the uv workspace, adds pyproject-nix, uv2nix, and pyproject-build-systems inputs to flake.nix, and configures the Python environment to use pyProject.mkVirtualEnv.

4.4.3.2 Alternative R Options: {renv} & Git Repositories

If your project already contains an renv.lock file, set:

[r-dependencies]
resolver = "renv"

When resolver = "renv", T automatically discovers all R dependencies from renv.lock:

  • CRAN packages are read from each entry’s Repository or Bioconductor field and mapped to pkgs.rPackages.*.
  • GitHub/GitLab packages are fetched via builtins.fetchGit using the RemoteHost, RemoteUsername, RemoteRepo, and RemoteSha fields (supports api.github.com and gitlab.com).
  • Remotes are parsed and injected as buildInputs for dependent Git packages.
  • Base R packages (R, methods, stats, etc.) are automatically filtered out.

No packages list is required in tproject.toml; renv.lock serves as the single source of truth. Run t update to regenerate flake.nix.

Note: This does not install the exact package versions locked in renv.lock. Instead, it installs the package versions available in Nixpkgs at the date defined in your tproject.toml ([nixpkgs].date).

To install R packages directly from remote Git repositories (e.g., GitHub), declare them directly in tproject.toml:

[r-dependencies]
packages = ["dplyr"]
my_pkg = { git = "https://github.com/user/my-pkg", rev = "abc123def456" }

The rev field must be a full Git commit hash. Each Git package is injected into every R pipeline node’s buildInputs. When using resolver = "renv", Git packages declared in tproject.toml are automatically merged with those in renv.lock.

4.4.4 System Dependencies and LaTeX

Beyond language packages, you can declare system tools and LaTeX packages required by your project.

4.4.4.1 Additional Tools

Use [additional-tools] to make CLI utilities, compilers, or system libraries available in your shell and pipeline sandboxes:

[additional-tools]
packages = ["git", "jq", "gawk", "pandoc", "typst"]

4.4.4.2 LaTeX Support

If your project generates PDFs or reports, use the [latex] section. T automatically provides a texlive environment (starting from scheme-small). Simply list any extra LaTeX packages needed:

[latex]
packages = ["amsmath", "blindtext", "physics", "hyperref"]

4.5 Summary

Traditional tools like {renv} or Python’s venv only capture part of the reproducibility puzzle. They track package versions but not the language version itself, nor system-level dependencies like GDAL or Java. This means your project can still break on a different machine, or even on your own machine after a system update.

Nix solves this by managing everything: R, Python, all packages, and all system dependencies. T builds on top of Nix to solve the orchestration problem: it provides a language for describing how computations in different runtimes interact, and it enforces reproducibility at every step.

When you define a pipeline with T, you get a complete, self-contained specification that anyone can use to recreate the exact same results, on any machine, at any point in the future.

In the next chapter, we will get our hands on the machinery and build our first pipeline.


  1. https://github.com/b-rodrigues/t_demos/tree/master/yanai_lercher_2020_t↩︎

  2. https://tstats-project.org↩︎