Nim Study Projects
ref object nodes, a generic container verified by real unit tests, and a command-line journal that reads its options, writes JSON and handles the failure paths. Type them yourself, run them, then break them on purpose.
Each project compiles with a single command and imports only the standard library. Keep the compiler at hand: nim c -r project.nim compiles, links and runs, and every diagnostic it prints is worth reading before you edit a line.
Linked List with Ref Objects
A linked list is the classic exercise for heap allocation and nil-terminated traversal. In Nim the node type is a ref object: the value lives on the heap, the variable holds a reference, and an unassigned reference is nil — the null case is explicit and checked by the compiler in strict modes.
Node, Prepend and Sum
type
Node = ref object ## one heap-allocated cell
value: int
next: Node ## nil by default: the terminator
proc prepend(head: var Node; value: int) =
## `head` is a var parameter: the assignment below must be visible to the
## caller, so the proc receives the reference, not a copy of it.
head = Node(value: value, next: head)
proc sum(head: Node): int =
## Traversal is a plain while loop over references. No index, no bounds
## check: the loop condition is what keeps us inside the list.
var cur = head
while cur != nil:
result += cur.value
cur = cur.next
when isMainModule:
var head: Node
for n in [3, 2, 1]:
head.prepend n # method-call syntax on a `var Node` parameter
echo "sum = ", sum(head) # 6
Sorted Insert
Insertion is where the pointer dance matters: find the first node whose successor is larger, then splice. Writing it with a var parameter keeps the empty-list and head-insert cases in a single expression, and the loop invariant (everything before cur is smaller) is what makes the code obvious.
proc insertSorted(head: var Node; value: int) =
## Keep the list ascending. Three cases collapse into two branches:
## empty list, insert at head, insert after some node.
if head == nil or value < head.value:
head = Node(value: value, next: head)
return
var cur = head
while cur.next != nil and cur.next.value < value:
cur = cur.next # advance while the successor is still smaller
cur.next = Node(value: value, next: cur.next)
when isMainModule:
var sorted: Node
for n in [5, 1, 3, 4, 2]:
sorted.insertSorted n
# walk it and print: 1 2 3 4 5
var cur = sorted
while cur != nil:
stdout.write cur.value, " "
cur = cur.next
echo ""
ref object is reclaimed by the automatic memory management (ARC/ORC in Nim 2.x) as soon as the last reference disappears, so there is no free call and no leak. Cycles are the one exception — ORC's cycle collector handles them, plain ARC does not. See the Memory Management lesson.
Generic Stack with Tests
A stack that works for any element type is the canonical first generic container. The type parameter T flows into the internal sequence, and each instantiation (Stack[int], Stack[string]) is compiled and type-checked separately — no casts, no void*.
A Generic Object
type
Stack[T] = object ## generic container: T is the element type
items: seq[T] ## the backing store grows and shrinks as needed
proc push[T](s: var Stack[T]; value: T) =
s.items.add value
proc pop[T](s: var Stack[T]): T =
## Return by popping the last element. The failure path raises a catchable
## exception instead of returning a sentinel value the caller might ignore.
if s.items.len == 0:
raise newException(ValueError, "pop from an empty stack")
result = s.items[^1] # ^1 is the last index: len - 1
s.items.setLen(s.items.len - 1)
proc peek[T](s: Stack[T]): T =
if s.items.len == 0:
raise newException(ValueError, "peek on an empty stack")
s.items[^1]
proc isEmpty[T](s: Stack[T]): bool = s.items.len == 0
proc depth[T](s: Stack[T]): int = s.items.len
Tests with std/unittest
A container without tests is a guess. std/unittest is part of the standard library: wrap the checks in suite and test, use check for boolean facts, and expect to assert that a statement raises. The block below runs with nim c -r stack.nim — no test runner to install.
when isMainModule:
import std/unittest
suite "Stack[int]":
test "push then pop returns the last value first (LIFO)":
var s: Stack[int]
s.push 1
s.push 2
s.push 3
check s.depth == 3
check s.pop() == 3
check s.pop() == 2
check s.depth == 1
test "peek does not remove the element":
var s: Stack[int]
s.push 42
check s.peek() == 42
check s.depth == 1
test "popping an empty stack raises ValueError":
var s: Stack[int]
expect ValueError:
discard s.pop() # the exception is the expected outcome
test "works for strings too, without any change":
var names: Stack[string]
names.push "ada"
names.push "grace"
check names.pop() == "grace"
check names.isEmpty == false
Command-Line Journal
The third project is the one you will actually keep using: a notebook that stores entries in a JSON file. It is split in two modules on purpose — journal.nim owns the data and knows nothing about the terminal, main.nim parses arguments and prints. That split is what makes the library unit-testable and the CLI replaceable.
The Data Module
# journal.nim — data in, data out. No echo, no quit, no assumptions
# about how the caller obtained the text.
import std/[json, os, times]
type
Entry* = object ## `*` exports the type to importing modules
date*: string ## ISO-like timestamp: yyyy-MM-dd HH:mm
text*: string
proc newEntry*(text: string): Entry =
Entry(date: now().format("yyyy-MM-dd HH:mm"), text: text)
proc toJson*(e: Entry): JsonNode =
## `%*{...}` builds a JsonNode from literals but type-checks the values,
## so a typo in a field name is a compile error, not a runtime surprise.
%*{"date": e.date, "text": e.text}
proc fromJson*(node: JsonNode): Entry =
## `getStr` raises on a missing key or a wrong type. For a corrupt file that
## is the behaviour we want: fail loudly where the damage is visible.
Entry(date: node["date"].getStr, text: node["text"].getStr)
proc load*(path: string): seq[Entry] =
## A missing file is not an error — it means "no entries yet".
if not fileExists(path):
return
let data = parseFile(path)
if data.kind != JArray:
raise newException(ValueError, path & " does not contain a JSON array")
for node in data:
result.add fromJson(node)
proc save*(path: string; entries: seq[Entry]) =
var data = newJArray()
for e in entries:
data.add e.toJson # method syntax on a proc whose first
writeFile(path, data.pretty) # parameter is an Entry
The Command-Line Front End
parseopt hands you one token at a time: cmdArgument for an ordinary word, cmdShortOption and cmdLongOption for -f and --file. Every error path ends in quit with a message and a non-zero status, because a tool that lies about failure is worse than one that fails.
# main.nim — the only place that talks to the user.
import std/[parseopt, os]
import journal # the module above; no prefix needed
const Usage = """
journal — a tiny command-line notebook
journal [--file:path] add "some text"
journal [--file:path] list
"""
when isMainModule:
var
file = "journal.json" # default location, relative to the working dir
command = ""
text = ""
for kind, key, value in getopt():
case kind
of cmdArgument:
if command.len == 0: command = key # first word: the verb
elif text.len == 0: text = key # second word: the payload
of cmdLongOption, cmdShortOption:
case key
of "file", "f": file = value
of "help", "h":
stdout.writeLine(Usage)
quit(0)
else:
quit("unknown option: " & key, QuitFailure)
of cmdEnd: discard
case command
of "add":
if text.len == 0:
quit("usage: journal add \"some text\"", QuitFailure)
var entries = load(file)
entries.add newEntry(text)
save(file, entries)
echo "saved ", entries.len, " entries to ", file
of "list":
let entries = load(file)
if entries.len == 0:
echo "no entries yet in ", file
else:
for i, e in entries:
echo align($(i + 1), 3), ". ", e.date, " ", e.text
of "":
quit(Usage, QuitFailure) # no command at all: show usage, fail
else:
quit("unknown command: " & command, QuitFailure)
Build the project and use it. The commands below are the whole workflow — including the failure cases you should trigger on purpose:
nim c -r main.nim add "read the Nim manual chapter on generics"
nim c -r main.nim add "try --mm:arc and compare binary sizes"
nim c -r main.nim list # prints two numbered entries
nim c -r main.nim --file:work.json add "another notebook"
nim c -r main.nim nonsense; echo "exit=$?" # unknown command, exit=1
Where to Go Next
These three projects cover allocation, generics, testing, JSON, files and option parsing — the daily work of a Nim program.
Taking the Projects Further
Extend them in the direction that interests you: give the journal an edit and delete command, make the stack persistent, or turn the linked list into a doubly linked list with a prev reference and see what the cycle collector does with it. When you want more material, the references page collects the official documentation, exercise sets and open-source code worth reading.