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.json

You'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.json

This 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.json

Each 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.json

The 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.json

The 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.hsep composes 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.annotate targets only the box it wraps, not its neighbors
  • Box.nullBox has 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.

Full example