Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Non-string labels render instead of disappearing. Any value that isn't an array is stringified, at any depth, and can be a parent node: `treeify(['deploys', [2025, ['spring']]])` renders `2025` with a child.
- `LICENSE` file (MIT). The README had linked to it since the beginning, but it was never committed. It now ships in the npm tarball.
- `exports`, `sideEffects` and `engines` fields in `package.json`.
- Continuous integration on GitHub Actions: tests and build across Node.js 22, 24 and 26.
- This changelog.

### Changed

- **Breaking:** input validation now throws a `TypeError` rather than an `Error`, with a message naming what it received: `array-treeify: expected the first element to be a string, received number (1)`. It used to be `Error: First element must be a string`.
- **Breaking:** input that is not an array now throws instead of returning an empty string. `treeify(null)` used to render nothing at all, which quietly hid the mistake.
- **Breaking:** `treeify([undefined])` now throws. It is the same failure as any other non-string first element, and used to return an empty string.
- **Breaking:** requires Node.js 22 or newer. Node.js 20 reached end of life on 2026-04-30.
- `treeify` accepts numbers, bigints and booleans as labels without a cast.
- Development toolchain: Biome 2 and TypeScript 7. `tsx` is gone — tests are `.ts` files run directly by `node --test` using Node's built-in type stripping, and `npm run typecheck` now covers the test files, which it never did before.

`treeify([])` still returns an empty string.

## [0.1.5] - 2025-05-08

### Changed

- More permissive input type.

## [0.1.4] - 2025-05-07

### Changed

- `TreeInput` accepts an empty array.
- Upgraded dependencies.

## [0.1.3] - 2025-03-30

### Added

- `chars` option for custom tree characters.
- `plain` option for whitespace-only output.

## [0.1.2] - 2025-03-29

### Changed

- Tests are no longer published to npm.

## [0.1.1] - 2025-03-28

### Added

- Initial release.

[Unreleased]: https://github.com/tbeseda/array-treeify/compare/0.1.5...HEAD
[0.1.5]: https://github.com/tbeseda/array-treeify/compare/0.1.4...0.1.5
[0.1.4]: https://github.com/tbeseda/array-treeify/compare/0.1.3...0.1.4
[0.1.3]: https://github.com/tbeseda/array-treeify/compare/0.1.2...0.1.3
[0.1.2]: https://github.com/tbeseda/array-treeify/compare/0.1.1...0.1.2
[0.1.1]: https://github.com/tbeseda/array-treeify/releases/tag/0.1.1
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,10 @@ Lumon Industries

## Input Format

> **Disclaimer:**
> The exported `TreeInput` type (`Array<string | TreeInput>`) is intentionally flexible to support dynamic and programmatic tree construction. However, TypeScript cannot enforce at the type level that the first element is a string. This requirement is checked at runtime by the `treeify` function, which will throw an error if the first element is not a string. Please ensure your input arrays follow this convention.

The `treeify` function accepts arrays with the following structure:

1. First element must be a string (the root node)
2. Subsequent elements can be strings (nodes at same level) or arrays (children of previous node)
2. Subsequent elements can be labels (nodes at same level) or arrays (children of previous node)
3. Arrays can be nested to any depth

```typescript
Expand All @@ -167,6 +164,35 @@ The `treeify` function accepts arrays with the following structure:
['root', ['child', ['grandchild']]] // Grandchildren
```

### Labels

Any value that isn't an array is a label. Non-strings are stringified, so numbers and booleans work anywhere a string does — including as parent nodes:

```typescript
console.log(treeify(['deploys', [2025, ['spring', 'summer'], 2026, ['q1']]]))
/*
deploys
├─ 2025
│ ├─ spring
│ └─ summer
└─ 2026
└─ q1
*/
```

### Errors

`treeify` throws a `TypeError` when it can't render what it was given:

- the input is not an array — `array-treeify: expected an array, received null`
- the first element is not a string — `array-treeify: expected the first element to be a string, received number (1)`

An empty array returns an empty string, so `treeify([])` is a safe way to say "nothing to render".

### Types

The exported `TreeInput` type (`Array<string | TreeInput>`) is intentionally permissive so trees can be assembled programmatically — build an array up with `push` and hand it over. A tuple type such as `[string, ...(string | TreeInput)[]]` *could* require a string first element at compile time, but it would rule out that dynamic construction, so the rule is enforced at runtime instead.

## Options

- `chars`: Custom characters for the tree. Defaults to Unicode box-drawing characters.
Expand Down
46 changes: 42 additions & 4 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,53 @@ second

test('first element must be a string', () => {
assert.throws(() => treeify([null, ['child']] as unknown as TreeInput), {
message: 'First element must be a string',
name: 'TypeError',
message:
'array-treeify: expected the first element to be a string, received null',
})
assert.throws(() => treeify([1, ['child']] as unknown as TreeInput), {
message: 'First element must be a string',
name: 'TypeError',
message:
'array-treeify: expected the first element to be a string, received number (1)',
})
assert.throws(() => treeify([undefined] as unknown as TreeInput), {
name: 'TypeError',
message:
'array-treeify: expected the first element to be a string, received undefined',
})
})

test('input must be an array', () => {
for (const input of [null, undefined, 'root', 42, {}]) {
assert.throws(() => treeify(input as unknown as TreeInput), {
name: 'TypeError',
message: /^array-treeify: expected an array, received /,
})
}
})

test('empty or invalid input returns empty string', () => {
test('empty input returns empty string', () => {
assert.strictEqual(treeify([] as unknown as TreeInput), '')
assert.strictEqual(treeify([undefined] as unknown as TreeInput), '')
})

test('non-string labels are stringified rather than dropped', () => {
assert.strictEqual(
treeify(['root', [1, true, null]] as unknown as TreeInput),
`root
├─ 1
├─ true
└─ null`,
)
})

test('a non-string label can be a parent node', () => {
assert.strictEqual(
treeify(['root', [2025, ['q1', 'q2'], 'note']] as unknown as TreeInput),
`root
├─ 2025
│ ├─ q1
│ └─ q2
└─ note`,
)
})
})
112 changes: 63 additions & 49 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,36 @@
export type TreeInput = Array<string | TreeInput>

/**
* Represents a node in the tree structure.
* Can be either a string (a leaf node) or an array of TreeNodes (a branch with children).
* A value rendered as a node label. Anything that isn't an array is a label;
* non-strings are stringified.
*/
type TreeNode = string | TreeNode[]
type TreeLeaf = string | number | bigint | boolean

/**
* Flexible input type that accepts any array.
* Flexible input type that accepts labels and nested arrays.
* Runtime validation ensures the first element is a string.
*/
type FlexibleTreeInput = readonly (string | unknown[])[]
type FlexibleTreeInput = readonly (TreeLeaf | readonly unknown[])[]

/**
* @description An array is a branch (the children of the node before it);
* anything else is a leaf.
*/
function isBranch(node: unknown): node is readonly unknown[] {
return Array.isArray(node)
}

/**
* @description Describes a value for error messages without dumping its contents.
*/
function describe(value: unknown): string {
if (value === null) return 'null'
if (Array.isArray(value)) return 'an array'
const type = typeof value
if (type === 'number' || type === 'bigint' || type === 'boolean')
return `${type} (${String(value)})`
return type
}

/**
* ASCII characters used to render the tree.
Expand Down Expand Up @@ -45,8 +65,9 @@ const EMPTY_CHARS: TreeChars = {
*
* The expected input format is a hierarchical structure where:
* - The first element must be a string (the root node)
* - String elements represent nodes at the current level
* - Array elements following a string represent the children of the previous node
* - Label elements represent nodes at the current level. Anything that isn't an
* array is a label, and non-strings are stringified
* - Array elements following a label represent the children of that node
* - Nested arrays create deeper levels in the tree
*
* Examples of supported formats:
Expand All @@ -62,7 +83,9 @@ const EMPTY_CHARS: TreeChars = {
* - `chars` {TreeChars} - Custom characters for the tree. Defaults to Unicode box-drawing characters.
* - `plain` {boolean} - Whether to use plain whitespace characters instead of Unicode box-drawing characters.
*
* @returns {string} A string containing the tree representation
* @returns {string} A string containing the tree representation. An empty array returns an empty string.
*
* @throws {TypeError} If `list` is not an array, or its first element is not a string.
*
* @example
* treeify(['root', ['child1', 'child2', ['grandchild']]])
Expand All @@ -78,10 +101,15 @@ export function treeify(
plain?: boolean
},
): string {
if (!Array.isArray(list) || list.length === 0) return ''
if (list[0] === undefined) return ''
if (!Array.isArray(list))
throw new TypeError(
`array-treeify: expected an array, received ${describe(list)}`,
)
if (list.length === 0) return ''
if (typeof list[0] !== 'string')
throw new Error('First element must be a string')
throw new TypeError(
`array-treeify: expected the first element to be a string, received ${describe(list[0])}`,
)

let chars = DEFAULT_CHARS
if (options?.plain) chars = EMPTY_CHARS
Expand All @@ -95,18 +123,15 @@ export function treeify(
while (i < list.length) {
const node = list[i]

if (typeof node === 'string') {
// add strings here
result.push(node)
i++
} else if (Array.isArray(node)) {
if (isBranch(node)) {
// array is the children of the previous item
renderTreeNodes(node, '', result, chars)
i++
} else {
// idk. skip it.
i++
// everything else is a label
result.push(String(node))
}

i++
}

return result.join('\n')
Expand All @@ -116,51 +141,44 @@ export function treeify(
* @description Renders tree nodes with appropriate ASCII indentation and branching
*/
function renderTreeNodes(
nodes: TreeNode[],
nodes: readonly unknown[],
indent: string,
result: string[],
chars: TreeChars,
): void {
if (!Array.isArray(nodes) || nodes.length === 0) return
if (nodes.length === 0) return

const parentNodeIndices = findParentNodeIndices(nodes)

let i = 0
while (i < nodes.length) {
const node = nodes[i]
const isParentNode = parentNodeIndices.has(i)

if (isParentNode) {
const parentIndex = i
const childrenIndex = i + 1
const stringNode = nodes[parentIndex] as string
const arrayNode = nodes[childrenIndex] as TreeNode[]

const isLast = !hasNextStringNode(nodes, childrenIndex + 1)
if (parentNodeIndices.has(i)) {
// a leaf followed by an array: that array holds its children
const children = nodes[i + 1] as readonly unknown[]
const isLast = !hasNextLeaf(nodes, i + 2)

const prefix = isLast ? chars.lastBranch : chars.branch
result.push(indent + prefix + stringNode)
result.push(indent + prefix + String(node))

// children with increased indent
const childIndent = indent + (isLast ? chars.space : chars.pipe)
renderTreeNodes(arrayNode, childIndent, result, chars)
renderTreeNodes(children, childIndent, result, chars)

// skip both the parent node and its children array
i += 2
} else if (typeof node === 'string') {
// string is simple. add it.
const isLast = !hasNextStringNode(nodes, i + 1)
const prefix = isLast ? chars.lastBranch : chars.branch
result.push(indent + prefix + node)
i++
} else if (Array.isArray(node)) {
// (>_>)
} else if (isBranch(node)) {
// an array with no leaf before it. (>_>) render it a level deeper.
const isLast = i === nodes.length - 1
const childIndent = indent + (isLast ? chars.space : chars.pipe)
renderTreeNodes(node, childIndent, result, chars)
i++
} else {
// (0_o)
// a leaf is simple. add it.
const isLast = !hasNextLeaf(nodes, i + 1)
const prefix = isLast ? chars.lastBranch : chars.branch
result.push(indent + prefix + String(node))
i++
}
}
Expand All @@ -169,15 +187,11 @@ function renderTreeNodes(
/**
* @description Locate parent nodes in the array to handle nesting.
*/
function findParentNodeIndices(nodes: TreeNode[]): Set<number> {
function findParentNodeIndices(nodes: readonly unknown[]): Set<number> {
const parentNodeIndices = new Set<number>()

for (let i = 0; i < nodes.length; i++) {
if (
typeof nodes[i] === 'string' &&
i + 1 < nodes.length &&
Array.isArray(nodes[i + 1])
) {
if (!isBranch(nodes[i]) && isBranch(nodes[i + 1])) {
parentNodeIndices.add(i)
}
}
Expand All @@ -187,12 +201,12 @@ function findParentNodeIndices(nodes: TreeNode[]): Set<number> {

/**
* @description
* Determines if there's another string node after the given index.
* Determines if there's another leaf after the given index.
* Used to decide if the current node is the last at its level.
*/
function hasNextStringNode(nodes: TreeNode[], startIndex: number): boolean {
function hasNextLeaf(nodes: readonly unknown[], startIndex: number): boolean {
for (let i = startIndex; i < nodes.length; i++) {
if (typeof nodes[i] === 'string') {
if (!isBranch(nodes[i])) {
return true
}
}
Expand Down
Loading