A program forgets everything when it closes. To keep a to-do list, your settings or a high score until tomorrow, it writes them to a file. Section 05 shows how.
Data has
many dialects.
Learn how JSON, XML, and YAML shape the same information—and how to move between them with TypeScript.
Start exploring ↘Why JSON exists.
A program’s data lives in memory, and memory is wiped the moment the program stops. Whenever data has to go anywhere else, it must first be written out as plain text. JSON is the agreed set of rules for writing that text, so whoever reads it gets back exactly what was sent.
A weather app doesn’t store the forecast; a server does. The app asks, and the server answers with text like this:
{"city": "Chicago", "temp": 41, "rain": true}A Python script on a server and JavaScript in a browser can’t see each other’s memory, and they’re written in different languages. Both can read JSON.
Why not make up your own format?
You could save a person as one line of text, with commas between the fields. That works until a name contains a comma. Both programs below print with the same three lines; only the way they read the record differs.
// A homemade format: fields separated by commas
const line = "Lovelace, Ada,programmer,true";
const [fullName, role, active] = line.split(",");
console.log(`name: "${fullName}"`);
console.log(`role: "${role}"`);
console.log(`active: ${active} (${typeof active})`);$ node homemade.ts name: "Lovelace" role: " Ada" active: programmer (string)
Wrong. The comma inside the name split it in two, so every field after it shifted. And even when nothing shifts, splitting text only ever gives back text: active can never be a real true or false.
// The same record as JSON: quotes mark where text ends
const text =
'{"name":"Lovelace, Ada","role":"programmer","active":true}';
const { name: fullName, role, active } = JSON.parse(text);
console.log(`name: "${fullName}"`);
console.log(`role: "${role}"`);
console.log(`active: ${active} (${typeof active})`);$ node json.ts name: "Lovelace, Ada" role: "programmer" active: true (boolean)
Right. The quotes show exactly where the name ends, and true comes back as a real true/false value, because JSON’s rules say which values are text and which aren’t.
That’s the whole need: data has to leave a program as text, and JSON is the text format every language already agrees on. JSON.stringify() turns data into that text and JSON.parse() turns it back, so nobody has to write, or debug, their own reader.
One record. Three ways.
A person, expressed in three popular data formats.
{
"name": "Ada Lovelace",
"role": "programmer",
"active": true
}Compact, structured, and native to JavaScript. Keys and strings use double quotes.
<person> <name>Ada Lovelace</name> <role>programmer</role> <active>true</active> </person>
Element tags describe structure. Verbose, but supports attributes and schemas.
person: name: Ada Lovelace role: programmer active: true
Clean and human-friendly. Indentation carries meaning; spaces matter.
Parse once.
Work with data.
TypeScript adds types to JavaScript so the shape of your data is clear before you use it.
JSON is built in: JSON.parse() reads text and JSON.stringify() writes it. XML and YAML usually need a parser package, such as fast-xml-parser or yaml.
// Describe the data shape type Person = { name: string; role: string; active: boolean; }; // Parse JSON, then use it const person: Person = JSON.parse(input); console.log(person.name);
Translate the payload.
Edit the JSON, then convert it to XML or simple YAML.
Pick the right tool.
Choose based on the job, the ecosystem, and the people who maintain the data.
A .json file
as storage.
Variables live in memory and vanish when a program exits. To keep data, TypeScript writes it to a file: JSON.stringify() on save, JSON.parse() on load.
// tasks.ts: save and load a list with a plain .json file import { existsSync, readFileSync, writeFileSync } from "node:fs"; // Describe the shape of one record type Task = { id: number; title: string; done: boolean; }; const FILE = "tasks.json"; // Load: read the file's text, then JSON.parse it function load(): Task[] { if (!existsSync(FILE)) return []; // first run: no file yet return JSON.parse(readFileSync(FILE, "utf8")) as Task[]; } // Save: JSON.stringify the whole list and write it back function save(tasks: Task[]): void { writeFileSync(FILE, JSON.stringify(tasks, null, 2) + "\n"); } const tasks = load(); tasks.push({ id: tasks.length + 1, title: process.argv[2] ?? "Untitled", done: false, }); save(tasks); console.log(`${tasks.length} task(s) saved in ${FILE}:`); for (const t of tasks) console.log(` #${t.id} ${t.title}`);
$ node tasks.ts "Learn JSON" 1 task(s) saved in tasks.json: #1 Learn JSON $ node tasks.ts "Learn YAML" 2 task(s) saved in tasks.json: #1 Learn JSON #2 Learn YAML $ rm tasks.json $ node tasks.ts "Start over" 1 task(s) saved in tasks.json: #1 Start over
[
{
"id": 1,
"title": "Learn JSON",
"done": false
},
{
"id": 2,
"title": "Learn YAML",
"done": false
}
]Recent Node runs a .ts file directly (this output is from Node 22.23). If an older Node refuses it, run npx tsx tasks.ts instead.
What makes a .json file handy?
JSON.parse() is built into JavaScript and TypeScript, and Python, Go, PHP and C# ship JSON in their standard libraries. Java, C and Rust need one small library.
Open it in any text editor and see exactly what was saved. No special viewer, no binary format to decode.
No server, no install, no schema. It’s one file you can copy, email or back up like any other.
It’s plain text, so git shows exactly which lines changed. That’s why tasks.ts saves with JSON.stringify(tasks, null, 2): one value per line.
Most web APIs send and receive JSON, so a reply can be saved to disk as-is and loaded again later.
Objects, arrays, strings, numbers, booleans and null map straight onto a type like Task. Dates don’t: they come back as strings.
Good examples
package.jsonEvery Node and TypeScript project: its name, scripts and dependencies.tsconfig.jsonTypeScript’s compiler settings. It also allows comments, a relaxed variant called JSONC.package-lock.jsonThe exact version of every installed package. npm writes it; you don’t..vscode/settings.jsonVS Code editor preferences, for you or for one project.composer.jsonA PHP project’s dependencies, read by Composer.manifest.jsonA browser extension’s name, version and permissions.
settings.jsonTheme, language, window size: a small object read at startup.save-game.jsonHigh scores, a game’s progress, or a to-do list liketasks.json.cache.jsonA slow API’s answer, saved so the next run reads the file instead of calling again.fixtures.jsonSample records your tests load, so every run starts from the same data.
Does it store forever?
As long as the file does. Close the program, reboot the machine, come back next year: tasks.json is still on disk. The third run above only started over because the file was deleted. TypeScript itself stores nothing; its types disappear when the code runs, and every variable dies with the program.
So a file persists like a database does, but it isn’t one. Every save rewrites the whole file, every load reads all of it, and as Task[] is a promise to the compiler, not a check: a hand-edited file with a missing field or a wrong type loads without complaint. Fine for settings and small lists. When the data grows, or more than one user writes to it, move to a database such as SQLite.
| .JSON FILE | DATABASE | |
|---|---|---|
| Survives a restart | Yes, until the file is deleted | Yes |
| Each save | Rewrites the whole file | Changes only the rows touched |
| Find one record | Load everything, then search | Query it with SELECT … WHERE |
| Crash mid-save | Can leave a half-written file | Rolls back to the last good state |
| Two writers at once | Last save wins; the other is lost | Writes take turns; none is silently lost |
| Checks the shape | No, not at runtime | Constraints like NOT NULL and CHECK |
Two languages,
one JSON.
A common setup: a Go server holds the data and a TypeScript program uses it. They share no memory and no code. Everything that passes between them is JSON text, and each side describes the same shape in its own language.
The contract
Three descriptions of the same record. The json:"…" tags are what connect Go’s capitalized field names to the lowercase keys TypeScript expects.
| JSON KEY | GO STRUCT FIELD | TYPESCRIPT TYPE |
|---|---|---|
"id" | ID int `json:"id"` | id: number; |
"title" | Title string `json:"title"` | title: string; |
"done" | Done bool `json:"done"` | done: boolean; |
// main.go: a tiny Go API that speaks JSON package main import ( "encoding/json" "fmt" "log" "net/http" "sync" ) // Task matches the TypeScript type field for field. // The `json:"..."` tags set the key names in the JSON. type Task struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"` } var ( mu sync.Mutex tasks = []Task{ {ID: 1, Title: "Learn JSON", Done: true}, } ) func main() { // GET /tasks: Go data -> JSON text http.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tasks) }) // POST /tasks: JSON text -> Go data http.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) { var t Task if err := json.NewDecoder(r.Body).Decode(&t); err != nil { http.Error(w, "bad JSON: "+err.Error(), http.StatusBadRequest) return } mu.Lock() t.ID = len(tasks) + 1 tasks = append(tasks, t) mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(t) }) fmt.Println("listening on http://localhost:8080") log.Fatal(http.ListenAndServe("localhost:8080", nil)) }
// client.ts: TypeScript talking to the Go API with JSON // The same shape as the Go struct type Task = { id: number; title: string; done: boolean; }; const API = "http://localhost:8080/tasks"; async function main(): Promise<void> { // Send: TypeScript data -> JSON text -> Go const res = await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "Learn Go", done: false }), }); if (!res.ok) throw new Error(await res.text()); const created: Task = await res.json(); console.log(`Go saved #${created.id}: ${created.title}`); // Receive: Go -> JSON text -> TypeScript data const tasks: Task[] = await (await fetch(API)).json(); for (const t of tasks) { console.log(` [${t.done ? "x" : " "}] #${t.id} ${t.title}`); } } main();
$ go run main.go listening on http://localhost:8080
$ node client.ts
Go saved #2: Learn Go
[x] #1 Learn JSON
[ ] #2 Learn Go
$ curl -s http://localhost:8080/tasks
[{"id":1,"title":"Learn JSON","done":true},{"id":2,"title":"Learn Go","done":false}]Leave the server running in one terminal and run the client in a second. The "GET /tasks" route patterns need Go 1.22 or newer; this output is from Go 1.26 and Node 22.23. New to Go? See Install Go.
How the JSON travels
JSON.stringify() turns { title: "Learn Go", done: false } into text, and fetch sends it to Go.
json.NewDecoder(r.Body).Decode(&t) reads that text into a Task struct, matching keys by the json tags.
json.NewEncoder(w).Encode(tasks) writes the whole list back out as text: the line curl shows above.
res.json() parses the text into a Task[], so the editor knows every task has id, title and done.
Gotchas
done boolLowercase fields are private in Go, andencoding/jsonsilently leaves them out. The JSON would have nodoneat all.ID int (no tag)Withoutjson:"…"tags Go writes the keys asID,TitleandDone, sot.idin TypeScript isundefined. Reading JSON in Go ignores case, which hides the mistake in the other direction.{"titel": "x"}Go ignores unknown keys and fills missing ones with zero values: an empty title, and no error. CallDisallowUnknownFields()on the decoder to reject it.const tasks: Task[]TypeScript doesn’t check JSON at runtime. If the Go struct changes, change the TypeScript type with it.
The Go struct and the TypeScript type are two descriptions of the same JSON. As long as they agree, either side can be rewritten, in any language, without the other noticing.