TypeScript: JSON.parse Returns any - Use unknown and a Type Guard (value is Task)
November 6, 2026
Welcome to episode 29 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! In the last episode, load tasks ended with JSON parse. But what does JSON parse really give back? Today we find out, and we make our program safe. We start with a new file called show.ts. Every code block below is the exact code and output from the video, ready to copy.
- All episodes are in the TypeScript from Zero playlist.
- Versions used: Node.js 26.13.0, npm 11.20.0, TypeScript 7.0.2.
Setup
We start in the terminal and open the project in VS Code:
cd tasks
touch src/show.ts
code .1. The hole
Show loads the tasks and prints each one with describe. We first run demo to write the file, and show prints our task. Now we damage the file by hand: a status called finished, which does not exist in our model. The program does not complain. It prints undefined. tsc trusts the types, but JSON parse hands back a type called any, and any switches the checks off.
$ node src/demo.ts
[]
[ { id: 1, title: 'Buy milk', status: 'todo' } ]src/show.ts:
import { describe } from "./task.ts";
import { loadTasks } from "./store.ts";
for (const task of await loadTasks()) {
console.log(describe(task));
}$ node src/show.ts
#1 Buy milk (not started)$ echo '[{"id":1,"title":"x","status":"finished"}]' > tasks.json2. unknown
The honest type is unknown: something that we know nothing about yet. We say that data is unknown, and try to return it. tsc refuses: type unknown is not assignable to type task list. TS2322. Good. It forces us to check the data before we trust it.
src/store.ts:
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { Task } from "./task.ts";
const FILE = "tasks.json";
export async function saveTasks(tasks: Task[]): Promise<void> {
await writeFile(FILE, JSON.stringify(tasks, null, 2));
}
export async function loadTasks(): Promise<Task[]> {
if (!existsSync(FILE)) {
return [];
}
const text = await readFile(FILE, "utf8");
const data: unknown = JSON.parse(text);
return data;
}$ npx tsc --noEmit
src/store.ts:17:3 - error TS2322: Type 'unknown' is not assignable to type 'Task[]'.
17 return data;
~~~~~~
Found 1 error in src/store.ts:173. Type guard
The check is a type guard. In task.ts we write a function is task. It takes an unknown value, and the return type says: value is Task. That is a promise to tsc: when this function returns true, the value is a task. Inside we check step by step. It must be an object. It needs a number id and a text title. And the fields of the status must fit: a doing task needs started at, and a done task needs finished at.
4. Using it
Back in the store. If data is not a list, or if not every item is a task, we throw an error. After that check, tsc knows that data is a list of tasks, and the return line is happy. Try it: the damaged file now stops the program with a clear message, instead of a mysterious undefined.
src/store.ts:
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { Task } from "./task.ts";
import { isTask } from "./task.ts";
const FILE = "tasks.json";
export async function saveTasks(tasks: Task[]): Promise<void> {
await writeFile(FILE, JSON.stringify(tasks, null, 2));
}
export async function loadTasks(): Promise<Task[]> {
if (!existsSync(FILE)) {
return [];
}
const text = await readFile(FILE, "utf8");
const data: unknown = JSON.parse(text);
if (!Array.isArray(data) || !data.every(isTask)) {
throw new Error("tasks.json has the wrong format");
}
return data;
}src/task.ts:
export type Task =
| { id: number; title: string; status: "todo" }
| { id: number; title: string; status: "doing"; startedAt: string }
| { id: number; title: string; status: "done"; finishedAt: string };
export function describe(task: Task): string {
switch (task.status) {
case "todo":
return `#${task.id} ${task.title} (not started)`;
case "doing":
return `#${task.id} ${task.title} (since ${task.startedAt})`;
case "done":
return `#${task.id} ${task.title} (finished ${task.finishedAt})`;
}
}
export function isTask(value: unknown): value is Task {
if (typeof value !== "object" || value === null) {
return false;
}
const v = value as Record<string, unknown>;
if (typeof v.id !== "number" || typeof v.title !== "string") {
return false;
}
if (v.status === "todo") {
return true;
}
if (v.status === "doing") {
return typeof v.startedAt === "string";
}
if (v.status === "done") {
return typeof v.finishedAt === "string";
}
return false;
}$ npx tsc --noEmit$ node src/show.ts
file:///home/mah/tasks/src/store.ts:19
throw new Error("tasks.json has the wrong format");
^
Error: tasks.json has the wrong format
at loadTasks (file:///home/mah/tasks/src/store.ts:19:11)
at async file:///home/mah/tasks/src/show.ts:4:20
Node.js v26.13.05. Healthy file
We delete the damaged file and write a healthy one again with demo, and show works as before. The bad data is stopped at the door, and everything inside can trust the types.
$ rm tasks.json$ node src/demo.ts
[]
[ { id: 1, title: 'Buy milk', status: 'todo' } ]$ node src/show.ts
#1 Buy milk (not started)Recap
Today: JSON parse returns any, so we treat the data as unknown. A type guard, with value is, checks it and teaches tsc the type. Next episode: error handling.