16. Files and saving data
Every program you’ve written so far forgets everything when it ends. Run it again, and it starts from scratch. Real programs remember things: your settings, your notes, your best score. They do that by saving data in files.
In this lesson you’ll learn:
- what files and folders are, and how a program finds them
- how to read, write and add to a text file
- how to check whether a file exists, and build paths with
joinPath - where a program should keep its own files, with
appDataFolder - how to save your structs as JSON and load them back
- how to split a program into several files in a folder
At the end, you’ll build a high-score table that remembers every score between runs.
Files and folders
Section titled “Files and folders”A file is a named piece of data on the disk: a photo, a song, a text document. Unlike the variables in your program, a file stays there after the program ends, and after the computer is switched off.
Files live in folders (also called directories), and folders can hold
other folders. A path is the address of a file: the folders you go
through to reach it, separated by /:
/Users/ada/Documents/notes.txtThat’s a full path: it starts at the top of the disk. A path like
notes.txt or notes/today.txt, which doesn’t start with /, is a
relative path: it’s relative to the current folder, the folder you were
in when you started the program. When you type tessel run hello.tsl in a
terminal, the current folder is the one the terminal is in.
In Tessel, paths are ordinary strings.
Writing and reading a file
Section titled “Writing and reading a file”writeFile saves text into a file, and readFile reads it back:
fn main() { let ok = writeFile(path: "hello.txt", text: "Hello, file!\n") print("saved: {ok}") if let text = readFile("hello.txt") { print("the file says: {text.trim()}") }}saved: truethe file says: Hello, file!After you run this, there’s a new file called hello.txt next to your
program. Open it in a text editor: it holds exactly the text you wrote.
A few things to notice:
writeFilereplaces the file if it already exists. The old contents are gone.writeFilereturns aBool:trueif it worked. Saving can fail (the disk is full, the folder doesn’t exist, you’re not allowed to write there), so it tells you.readFilereturns aString?, an optional, likereadLine()does. It givesnilwhen the file doesn’t exist or can’t be read. That’s why the example usesif let.- The text ends with
"\n", a line break, because text files usually end with one.trim()removes it before printing, so there’s no empty line.
Why an optional? Because a missing file is normal. The first time a program runs, it hasn’t saved anything yet. Tessel makes you decide what to do in that case:
fn main() { if let text = readFile("missing.txt") { print(text) } else { print("no such file") } let notes = readFile("missing.txt") ?? "" print("{notes.count} characters")}no such file0 characters?? "" is often the simplest choice: “the file’s text, or nothing if there
isn’t one yet”.
Finding out why: readText
Section titled “Finding out why: readText”readFile tells you that reading failed, but not why. The file might not
exist, or you might not have permission to read it, or it might be a folder.
When the reason matters (to show the user, say), use readText instead. It
returns a Result<String>, the built-in enum from
lesson 15:
fn main() { match readText("missing.txt") { .ok(text) -> print(text) .failure(e) -> print(e.message) }}can't read missing.txt: there's no such file or folderAnd inside a function that returns a Result, try passes the reason on to
the caller:
fn wordCount(path: String) -> Result<Int> { let text = try readText(path) Result.ok(value: text.words().count)}
fn main() { writeFile(path: "note.txt", text: "three short words") for path in ["note.txt", "missing.txt"] { match wordCount(path: path) { .ok(n) -> print("{path}: {n} words") .failure(e) -> print(e.message) } }}note.txt: 3 wordscan't read missing.txt: there's no such file or folderwriteText and appendText work the same way for writing. Since they have
no value to give back, they return an Error?: nil if it worked, or the
reason it didn’t. try writeText(path: p, text: t) passes that on too.
Adding to a file
Section titled “Adding to a file”To add text at the end of a file instead of replacing it, use appendFile.
If the file doesn’t exist yet, it’s created. This is handy for a log, a
diary, or anything that grows line by line:
fn main() { writeFile(path: "log.txt", text: "Log started\n") appendFile(path: "log.txt", text: "Opened the door\n") appendFile(path: "log.txt", text: "Closed the door\n") print((readFile("log.txt") ?? "").trim())}Log startedOpened the doorClosed the doorEach line ends with \n. Without it, the three pieces of text would run
together on one line.
To go through a file line by line, read it and split it with lines(),
which you met in lesson 8:
for line in (readFile("log.txt") ?? "").lines() { print("> {line}")}> Log started> Opened the door> Closed the doorFolders, and checking what’s there
Section titled “Folders, and checking what’s there”fileExists tells you whether a file (or folder) exists, createFolder
makes a folder, and listFolder gives the names of what’s inside one.
To build a path from a folder and a name, use joinPath. You could glue
strings together with "{folder}/{name}", but joinPath gets the details
right: it doesn’t add a second / when the folder already ends in one, and
on Windows it uses \, which is what Windows expects.
fn main() { createFolder("notes") let path = joinPath("notes", "today.txt") print(path) print(writeFile(path: path, text: "Buy bread\n")) print(fileExists(path)) print(fileExists("notes/tomorrow.txt")) print(isFolder("notes")) for name in listFolder("notes") ?? [] { print("- {name}") }}notes/today.txttruetruefalsetrue- today.txtcreateFolder is needed because writeFile doesn’t create folders for you.
Without the folder, the write fails, and writeFile returns false:
fn main() { let ok = writeFile(path: "drafts/today.txt", text: "Buy bread\n") print("saved: {ok}")}saved: falseThere are more file functions (deleteFile, renameFile, copyFile,
fileSize and others). They’re all listed under
Files in the reference.
Where to keep your program’s files
Section titled “Where to keep your program’s files”Saving scores.json in the current folder works, but it has a problem:
the current folder changes. Start your program from another folder, and it
can’t find its file any more, and it leaves files lying around wherever it
was run.
Programs usually keep their own data in a special folder for that purpose.
appDataFolder gives you one, named after your program. It creates the
folder if it doesn’t exist yet:
fn main() { let folder = appDataFolder("HighScores") print(folder) print(isFolder(folder))}On a Mac this prints something like:
/Users/ada/Library/Application Support/HighScorestrue(with your own user name instead of ada). The file you’d save there is
then joinPath(appDataFolder("HighScores"), "scores.json").
Use a name that belongs to your program, so different programs don’t mix up each other’s files.
Saving structs as JSON
Section titled “Saving structs as JSON”Saving plain text is easy. But your data is usually more than text: a list
of structs, say. How do you turn a [Pet] into text, and back?
You could write the fields one per line and read them back yourself, but
that’s a lot of fiddly code for every type. Instead, Tessel can convert
your values to JSON, a standard text format for data that almost every
programming language can read. toJson makes the text:
struct Pet { name: String kind: String age: Int}
fn main() { let pets = [ Pet(name: "Rex", kind: "dog", age: 3), Pet(name: "Tom", kind: "cat", age: 5), ] print(toJson(pets[0])) let text = toJson(pets, pretty: true) print(text) writeFile(path: "pets.json", text: text)}{"name":"Rex","kind":"dog","age":3}[ { "name": "Rex", "kind": "dog", "age": 3 }, { "name": "Tom", "kind": "cat", "age": 5 }]A struct becomes an object, written in { }, with each field’s name and
value. A list becomes an array, written in [ ]. With pretty: true,
the text is spread over lines and indented, so it’s easy for people to read
too.
fromJson goes the other way. It can’t know by itself what the text is
supposed to be (a pet? a list of numbers?), so you tell it by giving the
result a type. Because the text might not match that type, it returns an
optional:
struct Pet { name: String kind: String age: Int}
fn main() { let text = readFile("pets.json") ?? "[]" let pets: [Pet] = fromJson(text) ?? [] for pet in pets { print("{pet.name} the {pet.kind} is {pet.age}") }}Rex the dog is 3Tom the cat is 5This is a second program: it knows nothing about the first one except the file it left behind. That’s what saving data is for.
The pattern readFile(…) ?? "[]" then fromJson(text) ?? [] means: if
there’s no file yet, or it can’t be read as a list of pets, start with an
empty list. Your program works the very first time, with no special case.
fromJson gives nil when the text isn’t what you expected, without saying
why. Its partner decodeJson returns a Result instead, with a reason:
struct Pet { name: String age: Int}
fn main() { let texts = ["[\{\"name\": \"Rex\", \"age\": 3}]", "[\{\"name\": \"Rex\"}]", "[\{\"name\": \"Rex\""] for text in texts { let result: Result<[Pet]> = decodeJson(text) match result { .ok(pets) -> print("{pets.count} pet(s)") .failure(e) -> print(e.message) } }}1 pet(s)the JSON doesn't have the shape of this typethis isn't valid JSON: EOF while parsing an object at line 1 column 15The second text is valid JSON, but its pet has no age. The third isn’t
complete JSON at all. (Here the Result<[Pet]> type on result tells
decodeJson what to read, just as with fromJson.)
If the text doesn’t fit the type, for example a number where the struct
expects text, fromJson gives nil. Note the \{ in these strings: a
plain { in a string starts interpolation, so a literal
brace is written \{.
struct Pet { name: String age: Int}
fn main() { let good: Pet? = fromJson("\{\"name\": \"Rex\", \"age\": 3}") let bad: Pet? = fromJson("\{\"name\": \"Rex\", \"age\": \"three\"}") print(good == nil) print(bad == nil)}falsetrueJSON handles numbers, Bool, String, lists, dictionaries, optionals,
enums, and structs made of those, nested as deeply as you like. The
Saving data as JSON guide has the details, including what
happens when you add a field to a struct later.
Splitting a program into files
Section titled “Splitting a program into files”As programs grow, one long file gets hard to find your way around. You can
split a program into several .tsl files in one folder:
highscores/ score.tsl the Score struct, and functions on scores storage.tsl saving and loading main.tsl fn mainThen run the whole folder instead of one file:
tessel run highscoresAll the .tsl files in the folder are one program. There’s nothing to
import: every file can use every struct and function from every other file,
in any order. The file names are up to you; main.tsl is just a common name
for the file with fn main.
Because the files share one set of names, each struct and function name can only be used once in the whole folder. The Programs and files page tells you more.
Worked example: a high-score table
Section titled “Worked example: a high-score table”Now let’s put it all together. This program asks for your name and your points, adds them to a table of the five best scores, saves the table, and prints it. Next time you run it, the old scores are still there.
The first file describes a score, and what you can do with a list of them:
// score.tsl: a score, and functions that work on lists of scores.
struct Score { name: String points: Int}
fn bestFirst(_ scores: [Score]) -> [Score] { scores.sorted(by: { a, b in a.points > b.points })}
fn printTable(_ scores: [Score]) { print("--- High scores ---") var place = 1 for score in scores { let name = score.name.padEnd(10) print("{place}. {name} {score.points}") place += 1 }}padEnd(10) adds spaces after the name until it’s 10 characters long, so
the points line up in a column.
The second file does the saving and loading. It’s the only part of the program that knows scores are stored in a JSON file:
// storage.tsl: saving and loading the table as JSON.
fn scoresFile() -> String { joinPath(appDataFolder("HighScores"), "scores.json")}
fn loadScores() -> [Score] { let text = readFile(scoresFile()) ?? "[]" let scores: [Score] = fromJson(text) ?? [] scores}
fn saveScores(_ scores: [Score]) -> Bool { writeFile(path: scoresFile(), text: toJson(scores, pretty: true))}The last file is the program itself:
fn main() { var scores = loadScores()
print("Your name?") let name = (readLine() ?? "").trim() print("Your points?") let points = Int((readLine() ?? "").trim()) ?? 0
if name.isEmpty { print("No name, no score!") } else { scores.append(Score(name: name, points: points)) scores = bestFirst(scores).prefix(5) if !saveScores(scores) { print("Couldn't save the scores.") } } printTable(scores)}prefix(5) keeps only the first five scores, so the table never grows
beyond the top five.
Run it three times. Here the answers are piped in with printf (each \n
is an Enter), so you can see all three runs at once. Piped answers don’t
appear on the screen the way typed ones do:
printf 'Ada\n120\n' | tessel run highscoresprintf 'Linus\n300\n' | tessel run highscoresprintf 'Grace\n250\n' | tessel run highscoresYour name?Your points?--- High scores ---1. Ada 120Your name?Your points?--- High scores ---1. Linus 3002. Ada 120Your name?Your points?--- High scores ---1. Linus 3002. Grace 2503. Ada 120Each run started fresh, yet the table kept growing: the scores were in the
file. Here’s what scores.json holds after the third run:
[ { "name": "Linus", "points": 300 }, { "name": "Grace", "points": 250 }, { "name": "Ada", "points": 120 }]Notice how the files split the work. main.tsl doesn’t know how scores are
stored; it just calls loadScores() and saveScores(…). If you later
decide to store them differently, only storage.tsl changes.
Common mistakes
Section titled “Common mistakes”Using the file’s text without unwrapping it
Section titled “Using the file’s text without unwrapping it”readFile returns an
optional, so you can’t use its result as a String straight away:
fn main() { let text = readFile("hello.txt") print(text.count)}error: this might be `nil` --> main.tsl:3:11 |3 | print(text.count) | ^^^^ optional value | = help: use `?.count` to use it only when there is a value, or unwrap it with `if let`Unwrap it with if let, or give a default with ??:
let text = readFile("hello.txt") ?? "".
Not telling fromJson what to read
Section titled “Not telling fromJson what to read”Without a type, Tessel can’t tell what the JSON should become:
let pets = fromJson(readFile("pets.json") ?? "[]")error: can't tell what type `fromJson` should read --> main.tsl:8:16 |8 | let pets = fromJson(readFile("pets.json") ?? "[]") | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = help: give the result a type, like `let project: Project? = fromJson(…)`Write let pets: [Pet] = fromJson(…) ?? [].
Leaving out the labels
Section titled “Leaving out the labels”writeFile has two parameters, and both need
their labels:
writeFile("hello.txt", "Hi")error: this argument needs its label `path:` --> main.tsl:2:15 |2 | writeFile("hello.txt", "Hi") | ^^^^^^^^^^^ | = help: write `path: …`
error: this argument needs its label `text:` --> main.tsl:2:28 |2 | writeFile("hello.txt", "Hi") | ^^^^ | = help: write `text: …`(readFile("hello.txt") is fine without a label: a function with only one
parameter lets you leave it out.)
Running one file of a folder program
Section titled “Running one file of a folder program”If you run only main.tsl, the
other files aren’t part of the program, so their names are missing:
tessel run highscores/main.tslerror: cannot find `loadScores` --> highscores/main.tsl:2:18 |2 | var scores = loadScores() | ^^^^^^^^^^ not foundRun the folder instead: tessel run highscores.
Exercises
Section titled “Exercises”1. A shopping list. Write a program that saves three items (one per
line) to shopping.txt, reads the file back, and prints how many items
there are, then each item with a - in front.
Solution
fn main() { writeFile(path: "shopping.txt", text: "eggs\nflour\nmilk\n") let text = readFile("shopping.txt") ?? "" let items = text.lines() print("{items.count} things to buy:") for item in items { print("- {item}") }}3 things to buy:- eggs- flour- milk2. Counting runs. Write a program that remembers how many times it has
been run. Keep the number in count.txt. The first run prints
This is your first run!, later ones You've run this program 2 times.
and so on.
Solution
fn main() { let path = "count.txt" let text = readFile(path) ?? "0" let count = (Int(text.trim()) ?? 0) + 1 writeFile(path: path, text: "{count}") if count == 1 { print("This is your first run!") } else { print("You've run this program {count} times.") }}Run three times:
This is your first run!You've run this program 2 times.You've run this program 3 times.If there’s no file yet, readFile gives nil, ?? "0" turns that into
"0", and the count starts at 1.
3. A journal. Write a program that reads lines with readLine() until
the input ends, adds each non-empty line to journal.txt with
appendFile, and then prints how many entries the journal has in total.
Solution
fn main() { let path = "journal.txt" while let line = readLine() { if !line.trim().isEmpty { appendFile(path: path, text: "{line.trim()}\n") } } let all = readFile(path) ?? "" print("The journal has {all.lines().count} entries.")}printf 'Went for a walk\nRead a book\n' | tessel run journal.tslprintf 'Learned about files\n' | tessel run journal.tslThe journal has 2 entries.The journal has 3 entries.4. Settings with defaults. Make a struct Settings with
name: String = "friend" and runs: Int = 0. Load it from
settings.json (or use Settings() if there’s nothing to load), add one
to runs, save it as pretty JSON, and print
Hello, friend! This is run number 1. Then edit the name in the JSON file
by hand and run the program again.
Solution
struct Settings { name: String = "friend" runs: Int = 0}
fn loadSettings(_ path: String) -> Settings { if let text = readFile(path) { if let settings: Settings = fromJson(text) { return settings } } Settings()}
fn main() { let path = "settings.json" var settings = loadSettings(path) settings.runs += 1 writeFile(path: path, text: toJson(settings, pretty: true)) print("Hello, {settings.name}! This is run number {settings.runs}.")}Hello, friend! This is run number 1.Hello, friend! This is run number 2.After the second run, settings.json holds:
{ "name": "friend", "runs": 2}Change "friend" to your own name, and the next run greets you.
5. One line per player. In the high-score table, the same player can
appear several times. Change main.tsl so each name appears only once: if
the name is already in the table, keep whichever score is higher. (Hint:
firstIndex(where:) finds the position of the first item that matches.)
Solution
Only main.tsl changes:
fn main() { var scores = loadScores()
print("Your name?") let name = (readLine() ?? "").trim() print("Your points?") let points = Int((readLine() ?? "").trim()) ?? 0
if name.isEmpty { print("No name, no score!") } else { if let i = scores.firstIndex(where: { s in s.name == name }) { if points > scores[i].points { scores[i].points = points } } else { scores.append(Score(name: name, points: points)) } scores = bestFirst(scores).prefix(5) if !saveScores(scores) { print("Couldn't save the scores.") } } printTable(scores)}Ada scores 120, then 90, then 200. After the third run:
Your name?Your points?--- High scores ---1. Ada 200Summary
Section titled “Summary”- Files keep data after your program ends. A path like
notes/today.txtis relative to the current folder; one starting with/is a full path. writeFile(path:text:)replaces a file,appendFile(path:text:)adds to its end, and both return whether they worked.readText(path)returns aResult<String>with the reason if it fails;decodeJsondoes the same for JSON.readFile(path)returns aString?:nilif there’s no file. Useif letor??for the first run.fileExists,createFolderandlistFolderlook after folders;joinPathbuilds paths correctly on every system.appDataFolder("Name")is the right place for a program’s own files.toJson(value, pretty: true)turns your structs and lists into text;let x: Type? = fromJson(text)reads them back.- A folder of
.tslfiles is one program: run it withtessel run folder. Every file sees every name, and each name must be unique.
Next: 17. Algorithms