Tutorial: Directory tree
In this tutorial you'll build a function that renders a directory tree from a nested data structure. The final result looks like this:
.
├── 📁 src
│ ├── 📄 Box.ts
│ ├── 📄 Annotation.ts
│ └── 📄 Ansi.ts
├── 📁 tests
│ └── 📄 box.test.ts
└── 📄 package.jsonYou'll get there step by step, starting from a flat list and composing
boxes with Box.hsep to keep each part independently styled.
Step 1: Define the data
A tree is a list of entries where each entry may have children:
type Entry = {
name: string;
children?: Entry[];
};
const filesystem: Entry[] = [
{
name: "src",
children: [
{ name: "Box.ts" },
{ name: "Annotation.ts" },
{ name: "Ansi.ts" },
],
},
{
name: "tests",
children: [{ name: "box.test.ts" }],
},
{ name: "package.json" },
];Step 2: Render a flat list
Start simple. Render each entry as a line of text:
const tree = Box.vcat(
filesystem.map((entry) => Box.text(entry.name)),
Box.left
);
console.log(Box.renderPrettySync(tree));src
tests
package.jsonThis gives you a flat list. Now add tree connectors.
Step 3: Add connectors
Tree connectors use ├── for middle items and └── for the last item.
Instead of concatenating strings, compose each part as a separate box
using Box.hsep:
const renderEntries = (entries: Entry[]) =>
Box.vcat(
entries.map((entry, i) => {
const isLast = i === entries.length - 1;
const connector = Box.text(isLast ? "└──" : "├──");
return Box.hsep([connector, Box.text(entry.name)], 1, Box.left);
}),
Box.left
);├── src
├── tests
└── package.jsonEach row is now two independent boxes joined with a 1-character gap. The connector and name can be styled or replaced independently.
Step 4: Recurse into children
To render children, call the function recursively with an updated prefix.
The prefix is itself a Box that grows at each level. For middle items
the prefix continues with │, for last items it continues with spaces:
const renderEntries = (
entries: Entry[],
prefix: Box.Box<never> = Box.nullBox
): Box.Box<never> => {
if (entries.length === 0) return Box.nullBox;
return Box.vcat(
entries.flatMap((entry, i) => {
const isLast = i === entries.length - 1;
const connector = Box.text(isLast ? "└──" : "├──");
const childPrefix = Box.hsep(
[prefix, Box.text(isLast ? " " : "│ ")].filter((b) => b.cols > 0),
1,
Box.left
);
const label =
prefix.cols > 0
? Box.hsep([prefix, connector, Box.text(entry.name)], 1, Box.left)
: Box.hsep([connector, Box.text(entry.name)], 1, Box.left);
const children = entry.children
? renderEntries(entry.children, childPrefix)
: Box.nullBox;
return children.rows > 0 ? [label, children] : [label];
}),
Box.left
);
};
const tree = Box.vcat([Box.text("."), renderEntries(filesystem)], Box.left);.
├── src
│ ├── Box.ts
│ ├── Annotation.ts
│ └── Ansi.ts
├── tests
│ └── box.test.ts
└── package.jsonThe key insight: the prefix is a box that accumulates continuation characters at each level. Since it's a separate box, it stays visually consistent regardless of how deeply nested the tree gets.
Step 5: Add styling with annotations
Because each part is its own box, you can annotate them independently. Style the name based on whether it's a directory or file, add an icon, and leave the connector unstyled:
import * as Ansi from "effect-boxes/Ansi";
const renderEntries = (
entries: Entry[],
prefix: Box.Box<Ansi.AnsiStyle> = Box.nullBox as Box.Box<Ansi.AnsiStyle>
): Box.Box<Ansi.AnsiStyle> => {
if (entries.length === 0) return Box.nullBox as Box.Box<Ansi.AnsiStyle>;
return Box.vcat(
entries.flatMap((entry, i) => {
const isLast = i === entries.length - 1;
const connector = Box.text(isLast ? "└──" : "├──");
const childPrefix = Box.hsep(
[prefix, Box.text(isLast ? " " : "│ ")].filter((b) => b.cols > 0),
1,
Box.left
);
const isDir = !!entry.children;
const icon = Box.text(isDir ? "📁" : "📄");
const name = Box.text(entry.name).pipe(
Box.annotate(isDir ? Ansi.bold : Ansi.dim)
);
const parts =
prefix.cols > 0
? [prefix, connector, icon, name]
: [connector, icon, name];
const label = Box.hsep(parts, 1, Box.left);
const children = entry.children
? renderEntries(entry.children, childPrefix)
: (Box.nullBox as Box.Box<Ansi.AnsiStyle>);
return children.rows > 0 ? [label, children] : [label];
}),
Box.left
);
};.
├── 📁 src
│ ├── 📄 Box.ts
│ ├── 📄 Annotation.ts
│ └── 📄 Ansi.ts
├── 📁 tests
│ └── 📄 box.test.ts
└── 📄 package.jsonThe annotation on name only affects that box. The connector and prefix
render without any styling, keeping the tree lines clean.
What you've learned
Box.hsepcomposes boxes horizontally with consistent spacing- Treating each part (prefix, connector, icon, name) as a separate box lets you style them independently
- The prefix accumulates as a box through recursive calls
Box.annotatetargets only the box it wraps, not its neighborsBox.nullBoxhas zero width, so filtering it out of the parts array avoids empty gaps at the root level
The same pattern works for any tree-shaped data: dependency graphs, org
charts, AST dumps, or nested menu structures. Change the Entry type and
the connector characters to suit your domain.