JSON in Deno: fetch, Response.json(), Deno KV, File I/O & TypeScript

Last updated:

Deno handles JSON through Web-standard APIs — the same APIs available in browsers. Response.json(data) in Deno's built-in HTTP server serializes any JavaScript value to JSON and sets Content-Type: application/json in one call. fetch("https://api.example.com/data").then(r => r.json()) parses JSON API responses with full TypeScript inference when typed as const data: MyType = await res.json(). File I/O uses JSON.parse(await Deno.readTextFile("config.json")) — no fs module or require() needed. Deno KV (Deno.openKv()) stores any JSON-serializable value natively: kv.set(["users", id], user) persists the object without a serialization step. The Deno standard library's @std/encoding/json provides JsonParseStream for streaming large NDJSON files without loading everything into memory. Deno 2.x supports Node.js fs and path modules via node: specifiers alongside Deno-native APIs. This guide covers HTTP JSON responses, fetch API, file I/O, Deno KV, streaming NDJSON, and TypeScript type inference with JSON.

HTTP JSON Responses with Response.json() and Deno.serve()

Deno.serve() is the built-in HTTP server introduced in Deno 1.35 and stabilized in Deno 2.x. It accepts a handler function that receives a Request and returns a Response — both standard Web API objects. Response.json(data) is the correct way to return JSON: it calls JSON.stringify(data) internally and sets the Content-Type: application/json; charset=UTF-8 header automatically. No Express, no res.setHeader(), no manual stringification.

// ── Minimal JSON HTTP server ──────────────────────────────────
// Run: deno run --allow-net server.ts

Deno.serve((req: Request): Response => {
  const url = new URL(req.url)

  // Route: GET /api/status
  if (req.method === "GET" && url.pathname === "/api/status") {
    return Response.json({ ok: true, timestamp: Date.now() })
  }

  // Route: GET /api/users/:id
  const match = url.pathname.match(/^\/api\/users\/(\w+)$/)
  if (req.method === "GET" && match) {
    const userId = match[1]
    const user = { id: userId, name: "Alice", email: "alice@example.com" }
    return Response.json(user)                    // 200 + JSON body
  }

  // 404 JSON error — pass status in second argument
  return Response.json(
    { success: false, error: "Not Found", path: url.pathname },
    { status: 404 },
  )
})

// ── Response.json() with custom headers ───────────────────────
function jsonResponse(data: unknown, status = 200): Response {
  return Response.json(data, {
    status,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Cache-Control": "no-store",
    },
  })
}

// ── Async handler with request body ───────────────────────────
Deno.serve(async (req: Request): Promise<Response> => {
  if (req.method === "POST" && new URL(req.url).pathname === "/api/echo") {
    const body = await req.json()       // parse incoming JSON
    return Response.json({ echo: body }) // reflect it back
  }
  return Response.json({ error: "Method Not Allowed" }, { status: 405 })
})

// ── Router pattern without a framework ────────────────────────
type Handler = (req: Request) => Response | Promise<Response>

const routes: Record<string, Record<string, Handler>> = {
  GET: {
    "/api/ping": () => Response.json({ pong: true }),
    "/api/health": () => Response.json({ status: "healthy", uptime: process.uptime?.() }),
  },
  POST: {
    "/api/items": async (req) => {
      const item = await req.json()
      // ... persist item
      return Response.json({ created: item }, { status: 201 })
    },
  },
}

Deno.serve((req) => {
  const url  = new URL(req.url)
  const handler = routes[req.method]?.[url.pathname]
  if (handler) return handler(req)
  return Response.json({ error: "Not Found" }, { status: 404 })
})

Response.json() handles all JSON-serializable values: plain objects, arrays, strings, numbers, booleans, and null. It rejects values that JSON.stringify() would reject — functions, undefined, circular references, and BigInt — throwing a TypeError. For BigInt, convert to a string before passing to Response.json(). The Deno.serve() call binds to 0.0.0.0:8000 by default; pass { port: 3000, hostname: "localhost" } as the second argument to customize. The server handles HTTP/1.1 and HTTP/2 automatically.

fetch() and JSON API Requests with TypeScript Types

Deno includes the global fetch() function with no import — it is a first-class Web API. The res.json() method parses the response body as JSON and returns Promise<any>. Annotate the receiving variable to get TypeScript type checking: const data: User = await res.json(). For runtime safety, combine with Zod to validate the parsed shape at the API boundary.

// ── Basic fetch + JSON parse ──────────────────────────────────
// Run: deno run --allow-net fetch.ts

interface User {
  id: number
  name: string
  email: string
}

const res = await fetch("https://jsonplaceholder.typicode.com/users/1")
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
const user: User = await res.json()
console.log(user.name) // "Leanne Graham" — typed as string

// ── Generic typed fetch helper ─────────────────────────────────
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, options)
  if (!res.ok) {
    const errorBody = await res.text()
    throw new Error(`HTTP ${res.status}: ${errorBody}`)
  }
  return res.json() as Promise<T>
}

const users = await fetchJson<User[]>("https://jsonplaceholder.typicode.com/users")
console.log(users.length) // 10 — typed as User[]

// ── POST JSON ─────────────────────────────────────────────────
interface CreateUserInput { name: string; email: string }
interface CreateUserResponse { id: number; name: string; email: string }

async function createUser(input: CreateUserInput): Promise<CreateUserResponse> {
  const res = await fetch("https://jsonplaceholder.typicode.com/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
  })
  if (!res.ok) throw new Error(`Failed to create user: ${res.status}`)
  return res.json() as Promise<CreateUserResponse>
}

const newUser = await createUser({ name: "Bob", email: "bob@example.com" })
console.log(newUser.id)  // 11 — typed as number

// ── AbortController for timeouts ──────────────────────────────
async function fetchWithTimeout<T>(url: string, ms = 5000): Promise<T> {
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), ms)
  try {
    const res = await fetch(url, { signal: controller.signal })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json()
  } finally {
    clearTimeout(timeout)
  }
}

// ── Zod validation at fetch boundary ──────────────────────────
import { z } from "npm:zod"

const UserSchema = z.object({
  id:    z.number(),
  name:  z.string(),
  email: z.string().email(),
})
type User = z.infer<typeof UserSchema>

async function fetchUser(id: number): Promise<User> {
  const res  = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
  const json = await res.json()
  return UserSchema.parse(json)  // throws ZodError if shape is wrong
}

// ── Fetch with authentication headers ─────────────────────────
const apiKey = Deno.env.get("API_KEY") ?? ""
const res2 = await fetch("https://api.example.com/data", {
  headers: {
    "Authorization": `Bearer ${apiKey}`,
    "Accept": "application/json",
  },
})
const data: unknown = await res2.json()

Deno requires the --allow-net flag (or --allow-net=jsonplaceholder.typicode.com for allowlist-style granular permissions) to make outbound network requests. When running scripts interactively without flags, Deno prompts for permission on first use. The --allow-net approach means network access is explicit and auditable — a significant security improvement over Node.js, which grants network access by default. Deno's fetch() supports HTTP/1.1, HTTP/2, and HTTP/3 (experimental), and handles redirects, credentials, and streaming response bodies.

Reading and Writing JSON Files with Deno.readTextFile

Deno replaces Node.js's fs module with a unified namespace of async methods under Deno.*. JSON file I/O combines Deno.readTextFile() (read as UTF-8 string) with JSON.parse(), and Deno.writeTextFile() with JSON.stringify(). All file operations require the --allow-read and --allow-write permission flags.

// ── Read JSON file ────────────────────────────────────────────
// Run: deno run --allow-read read.ts

interface Config {
  port: number
  host: string
  debug: boolean
}

// Async (preferred)
const raw = await Deno.readTextFile("config.json")
const config: Config = JSON.parse(raw)
console.log(config.port) // e.g. 8080

// Synchronous
const rawSync = Deno.readTextFileSync("config.json")
const configSync: Config = JSON.parse(rawSync)

// ── Write JSON file ───────────────────────────────────────────
// Run: deno run --allow-write write.ts

const data = { users: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] }

// Pretty-printed (indent = 2 spaces)
await Deno.writeTextFile("output.json", JSON.stringify(data, null, 2))

// Minified
await Deno.writeTextFile("output.min.json", JSON.stringify(data))

// ── Atomic write (write-then-rename) ──────────────────────────
// Prevents corrupt files if process crashes mid-write
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
  const tmp = `${path}.tmp`
  await Deno.writeTextFile(tmp, JSON.stringify(value, null, 2))
  await Deno.rename(tmp, path)
}
await writeJsonAtomic("config.json", { port: 3000, host: "localhost", debug: false })

// ── Read and update JSON (read-modify-write) ──────────────────
interface AppState { visits: number; lastSeen: string }

async function incrementVisits(path: string): Promise<void> {
  const state: AppState = JSON.parse(await Deno.readTextFile(path))
  state.visits += 1
  state.lastSeen = new Date().toISOString()
  await Deno.writeTextFile(path, JSON.stringify(state, null, 2))
}

// ── Append NDJSON line ────────────────────────────────────────
// Run: deno run --allow-write append.ts
async function appendNdjson(path: string, record: unknown): Promise<void> {
  const file = await Deno.open(path, { write: true, append: true, create: true })
  const line = JSON.stringify(record) + "\n"
  await file.write(new TextEncoder().encode(line))
  file.close()
}
await appendNdjson("events.ndjson", { event: "click", userId: 42, ts: Date.now() })

// ── Check file exists before reading ──────────────────────────
async function readJsonOrDefault<T>(path: string, fallback: T): Promise<T> {
  try {
    return JSON.parse(await Deno.readTextFile(path)) as T
  } catch (err) {
    if (err instanceof Deno.errors.NotFound) return fallback
    throw err
  }
}

const settings = await readJsonOrDefault("settings.json", { theme: "dark" })

// ── Node.js fs compatibility in Deno 2.x ──────────────────────
import { readFileSync, writeFileSync } from "node:fs"

const nodeJson = JSON.parse(readFileSync("config.json", "utf-8"))
writeFileSync("output.json", JSON.stringify(nodeJson, null, 2))

Deno.readTextFile() always reads the file as UTF-8. For files with a different encoding, use Deno.readFile() to get a Uint8Array and decode manually with new TextDecoder("latin-1"). JSON.parse() throws a SyntaxError on malformed JSON — always wrap in try/catch when reading externally-provided files. Deno 2.x added import.meta.dirname and import.meta.filename for resolving paths relative to the current module, replacing the Node.js __dirname pattern.

Deno KV: JSON-Native Key-Value Storage

Deno KV is a built-in key-value store that persists any JSON-serializable value without a serialization step. It is available via Deno.openKv() with no external dependencies. Locally it uses SQLite; on Deno Deploy it uses FoundationDB, providing global distribution and strong consistency. Keys are arrays of strings and numbers forming a hierarchical namespace.

// ── Open Deno KV ──────────────────────────────────────────────
// Run: deno run --unstable-kv kv.ts
// (--unstable-kv required in Deno < 2.0; stable in Deno 2.x)

interface User { id: string; name: string; email: string; createdAt: string }

const kv = await Deno.openKv()

// ── Set (write) a JSON-serializable object ────────────────────
const user: User = {
  id: "usr_01",
  name: "Alice",
  email: "alice@example.com",
  createdAt: new Date().toISOString(),
}

await kv.set(["users", user.id], user)
// Key: ["users", "usr_01"]  →  Value: { id: "usr_01", name: "Alice", ... }

// ── Get (read) a value ────────────────────────────────────────
const entry = await kv.get<User>(["users", "usr_01"])
console.log(entry.value?.name)  // "Alice" — typed as User | null

// entry.value is null if the key does not exist
// entry.versionstamp is used for optimistic concurrency

// ── Delete a key ──────────────────────────────────────────────
await kv.delete(["users", "usr_01"])

// ── List all users (prefix scan) ──────────────────────────────
const iter = kv.list<User>({ prefix: ["users"] })
for await (const entry of iter) {
  console.log(entry.key, entry.value?.name)
}

// ── Atomic transaction (all-or-nothing) ───────────────────────
// Decrement a counter only if it is > 0 (check-then-set)
async function decrementStock(productId: string): Promise<boolean> {
  const key = ["stock", productId]
  for (let attempt = 0; attempt < 3; attempt++) {
    const entry = await kv.get<number>(key)
    const count = entry.value ?? 0
    if (count <= 0) return false

    const res = await kv.atomic()
      .check(entry)                  // fail if key changed since we read it
      .set(key, count - 1)
      .commit()

    if (res.ok) return true          // committed successfully
    // else: another writer changed the key — retry
  }
  return false
}

// ── Session storage pattern ───────────────────────────────────
interface Session { userId: string; expires: number }

async function createSession(userId: string): Promise<string> {
  const sessionId = crypto.randomUUID()
  const session: Session = {
    userId,
    expires: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
  }
  // expireIn sets a TTL in milliseconds — KV auto-deletes after expiry
  await kv.set(["sessions", sessionId], session, { expireIn: 24 * 60 * 60 * 1000 })
  return sessionId
}

async function getSession(sessionId: string): Promise<Session | null> {
  const entry = await kv.get<Session>(["sessions", sessionId])
  return entry.value
}

// ── Use with Deno.serve() ─────────────────────────────────────
const kvGlobal = await Deno.openKv()

Deno.serve(async (req) => {
  const url = new URL(req.url)
  if (req.method === "GET" && url.pathname === "/api/users") {
    const users: User[] = []
    for await (const entry of kvGlobal.list<User>({ prefix: ["users"] })) {
      if (entry.value) users.push(entry.value)
    }
    return Response.json(users)
  }
  return Response.json({ error: "Not Found" }, { status: 404 })
})

Deno KV values are stored using the V8 structured serialization format, which supports more types than JSON: Date, Map, Set, ArrayBuffer, RegExp, and undefined. This means you can store a Date object directly — kv.set(["event", id], new Date()) — and read it back as a Date, not a string. The maximum value size is 65,536 bytes. On Deno Deploy, Deno KV uses globally consistent reads by default; pass { consistency: "eventual" } to kv.get() for lower latency at the cost of potentially stale data.

Streaming NDJSON with JsonParseStream

JsonParseStream from @std/encoding/json processes NDJSON (newline-delimited JSON) line by line, emitting a parsed JavaScript object for each line. This is the idiomatic Deno approach for processing large log files, event streams, or bulk data exports without loading everything into memory. The stream pipeline uses Web Streams API primitives — ReadableStream, TransformStream, and WritableStream.

// ── Install: deno add @std/encoding @std/streams
// Run: deno run --allow-read stream.ts

import { JsonParseStream } from "@std/encoding/json"
import { TextLineStream }  from "@std/streams/text-line-stream"

// ── Read NDJSON file line-by-line ─────────────────────────────
// data.ndjson:
//   {"id":1,"event":"click","userId":42}
//   {"id":2,"event":"purchase","userId":17}

interface Event { id: number; event: string; userId: number }

async function processNdjsonFile(path: string): Promise<void> {
  const file   = await Deno.open(path, { read: true })
  const stream = file.readable
    .pipeThrough(new TextDecoderStream())          // Uint8Array → string chunks
    .pipeThrough(new TextLineStream())              // chunks → individual lines
    .pipeThrough(new JsonParseStream())             // lines → parsed JS objects

  for await (const record of stream) {
    const event = record as Event
    console.log(`User ${event.userId} triggered: ${event.event}`)
  }
  // No file.close() needed — stream auto-closes
}

await processNdjsonFile("events.ndjson")

// ── Stream NDJSON from an HTTP endpoint ───────────────────────
async function streamNdjsonApi(url: string): Promise<void> {
  const res = await fetch(url)
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)

  const stream = res.body
    .pipeThrough(new TextDecoderStream())
    .pipeThrough(new TextLineStream())
    .pipeThrough(new JsonParseStream())

  for await (const record of stream) {
    console.log(record)
  }
}

// ── Write NDJSON (streaming output) ──────────────────────────
import { JsonStringifyStream } from "@std/encoding/json"

async function writeNdjson(path: string, records: AsyncIterable<unknown>): Promise<void> {
  const file = await Deno.open(path, { write: true, create: true, truncate: true })
  const readable = ReadableStream.from(records)

  await readable
    .pipeThrough(new JsonStringifyStream())        // objects → JSON strings
    .pipeThrough(new TextEncoderStream())           // strings → Uint8Array
    .pipeTo(file.writable)
}

// Generate 100 000 NDJSON records without building a giant array
async function* generateEvents(): AsyncIterable<Event> {
  for (let i = 0; i < 100_000; i++) {
    yield { id: i, event: "page_view", userId: Math.floor(Math.random() * 1000) }
  }
}
await writeNdjson("output.ndjson", generateEvents())

// ── Streaming JSON response from Deno.serve() ─────────────────
Deno.serve(async (_req) => {
  // Stream NDJSON rows from a database cursor
  const rows = generateEvents()
  const readable = ReadableStream.from(rows)
    .pipeThrough(new JsonStringifyStream())
    .pipeThrough(new TextEncoderStream())

  return new Response(readable, {
    headers: {
      "Content-Type": "application/x-ndjson",
      "Transfer-Encoding": "chunked",
    },
  })
})

JsonParseStream skips empty lines automatically, making it safe for NDJSON files with trailing newlines or blank separators. It throws a SyntaxError on malformed JSON lines — wrap the for await loop in a try/catch to handle per-line errors. For standard multi-document JSON (a single large array rather than one object per line), use @std/streams with a custom accumulator or the stream-json npm package via npm:stream-json.

TypeScript Type Guards for JSON Safety

Deno ships TypeScript support with no configuration. JSON.parse() and res.json() return any or unknown — type annotations on the receiving variable are compile-time assertions, not runtime checks. For production code, combine TypeScript interfaces with Zod runtime validation at every external JSON boundary: file reads, API responses, request bodies, and Deno KV values from external writers.

// ── Run: deno run --allow-read --allow-net types.ts

import { z } from "npm:zod"

// ── Zod schema for a JSON API response ────────────────────────
const ProductSchema = z.object({
  id:       z.number().int().positive(),
  name:     z.string().min(1),
  price:    z.number().positive(),
  inStock:  z.boolean(),
  tags:     z.array(z.string()).default([]),
  metadata: z.record(z.string(), z.unknown()).optional(),
})
type Product = z.infer<typeof ProductSchema>

// ── Validate fetch response ────────────────────────────────────
async function fetchProduct(id: number): Promise<Product> {
  const res = await fetch(`https://api.example.com/products/${id}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  const json = await res.json()
  return ProductSchema.parse(json)  // throws ZodError if shape is wrong
}

// ── Validate file JSON ────────────────────────────────────────
const ConfigSchema = z.object({
  port:    z.number().int().min(1024).max(65535),
  host:    z.string(),
  debug:   z.boolean().default(false),
  origins: z.array(z.string().url()).default([]),
})
type Config = z.infer<typeof ConfigSchema>

async function loadConfig(path: string): Promise<Config> {
  const raw = await Deno.readTextFile(path)
  const json = JSON.parse(raw)         // SyntaxError on malformed JSON
  return ConfigSchema.parse(json)      // ZodError on invalid shape
}

// ── Manual type guard (no Zod) ────────────────────────────────
function isProduct(value: unknown): value is Product {
  if (typeof value !== "object" || value === null) return false
  const obj = value as Record<string, unknown>
  return (
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    typeof obj.price === "number" &&
    typeof obj.inStock === "boolean"
  )
}

const raw: unknown = JSON.parse(`{"id":1,"name":"Widget","price":9.99,"inStock":true}`)
if (isProduct(raw)) {
  console.log(raw.name) // typed as string — TS knows it passed the guard
}

// ── Discriminated union type guard ────────────────────────────
const EventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
  z.object({ type: z.literal("submit"), formId: z.string() }),
  z.object({ type: z.literal("error"), message: z.string(), code: z.number() }),
])
type AppEvent = z.infer<typeof EventSchema>

function handleEvent(raw: unknown): void {
  const event = EventSchema.parse(raw)
  switch (event.type) {
    case "click":  console.log(`Click at ${event.x},${event.y}`); break
    case "submit": console.log(`Form submitted: ${event.formId}`); break
    case "error":  console.error(`Error ${event.code}: ${event.message}`); break
  }
}

// ── Safe JSON.parse wrapper returning Result<T> ────────────────
type Ok<T>  = { success: true;  value: T }
type Err    = { success: false; error: string }
type Result<T> = Ok<T> | Err

function parseJson<T>(text: string, schema: z.ZodSchema<T>): Result<T> {
  try {
    const json = JSON.parse(text)
    const value = schema.parse(json)
    return { success: true, value }
  } catch (err) {
    return { success: false, error: String(err) }
  }
}

const result = parseJson('{"port":8080,"host":"localhost"}', ConfigSchema)
if (result.success) {
  console.log(result.value.port) // 8080 — typed as number
}

Deno's built-in TypeScript runs without a build step. For strict null checking, Deno defaults to TypeScript's strict mode — JSON.parse() returns any, so annotating as unknown and using type guards or Zod is best practice. For Deno KV, the generic parameter kv.get<User>(key) types entry.value as User | null — always handle the null case (key not found). Deno also supports // @ts-nocheck per-file and // @deno-types for attaching type declarations to JavaScript modules.

Node.js Compatibility: Using require() and fs in Deno 2.x

Deno 2.x introduced full Node.js compatibility, including node: built-in module specifiers, npm package imports via npm: specifiers, and support for CommonJS modules. This allows Deno to run existing Node.js JSON-handling code without modification. import {'{ readFileSync }'} from "node:fs" works alongside Deno-native APIs in the same file.

// ── Deno 2.x Node.js compatibility ───────────────────────────
// Run: deno run --allow-read --allow-write compat.ts

// ── node:fs — Node.js file system API in Deno ─────────────────
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"

// Read JSON file with Node.js API
const raw = readFileSync("config.json", "utf-8")
const config = JSON.parse(raw)

// Write JSON with Node.js API
writeFileSync("output.json", JSON.stringify(config, null, 2))

// Async with fs/promises
const asyncRaw = await readFile("data.json", "utf-8")
const data = JSON.parse(asyncRaw)

// Path utilities
const configPath = path.join(import.meta.dirname ?? ".", "config.json")
const jsonStr = readFileSync(configPath, "utf-8")

// ── npm packages via npm: specifier ───────────────────────────
// No package.json needed — import directly from npm

import Ajv from "npm:ajv"
const ajv  = new Ajv()
const validate = ajv.compile({
  type: "object",
  properties: { name: { type: "string" }, age: { type: "number" } },
  required: ["name"],
})
const valid = validate({ name: "Alice", age: 30 })
console.log(valid) // true

// ── npm:zod (same as importing from npm:zod) ──────────────────
import { z } from "npm:zod"
const schema = z.object({ name: z.string(), age: z.number().optional() })
const parsed = schema.parse(JSON.parse('{"name":"Alice"}'))

// ── CommonJS require() in Deno 2.x ───────────────────────────
// Works with CJS npm packages automatically
import express from "npm:express"
const app = express()
app.use(express.json())  // parses JSON request bodies

app.get("/api/ping", (_req, res) => {
  res.json({ pong: true, runtime: "deno" })
})

app.listen(3000, () => console.log("Listening on :3000"))
// Run: deno run --allow-net --allow-read server.ts

// ── Mixing Deno and Node.js APIs ──────────────────────────────
// Read with Deno.readTextFile, validate with Node.js stream
const text   = await Deno.readTextFile("events.ndjson")
const lines  = text.split("\n").filter(Boolean)
const events = lines.map((line) => JSON.parse(line))

// Write with Node.js writeFileSync
writeFileSync("processed.json", JSON.stringify(events, null, 2))

// ── deno.json / deno.jsonc — project config ───────────────────
// deno.json replaces package.json for Deno projects
// {
//   "imports": {
//     "zod": "npm:zod@^3",
//     "@std/encoding": "jsr:@std/encoding@^1",
//   },
//   "tasks": {
//     "dev": "deno run --watch --allow-net --allow-read server.ts",
//     "test": "deno test --allow-read"
//   },
//   "compilerOptions": { "strict": true }
// }

// After adding to deno.json imports, use bare specifiers:
// import { z } from "zod"             (resolves to npm:zod)
// import { readTextFile } from "@std/fs"  (resolves to jsr:@std/fs)

Deno 2.x handles the vast majority of npm packages without modification. Packages that use Node.js-specific globals like __dirname, process.cwd(), or Buffer are polyfilled automatically. Packages that call native Node.js add-ons (.node files) are not supported, but pure-JavaScript packages — which includes all JSON-related libraries like Zod, Ajv, fast-json-stringify, and json2csv — work without changes. The deno.json imports field acts as an import map, allowing bare specifiers like import {'{ z }'} from "zod" after mapping "zod": "npm:zod@^3".

FAQ

How do I return a JSON response in Deno?

Use Response.json(data) in Deno's built-in HTTP server. This static method serializes any JSON-serializable JavaScript value, sets the Content-Type header to application/json; charset=UTF-8 automatically, and returns a Response object in a single call. With Deno.serve(), a complete JSON endpoint is three lines: Deno.serve((req) => Response.json({ ok: true, timestamp: Date.now() })). You can pass a second options argument to set the HTTP status code: Response.json({ error: "Not found" }, { status: 404 }). Response.json() is a Web API — the same method available in browsers and other runtimes — so code using it is inherently cross-runtime compatible. For structured error responses, pass an object with a consistent shape and a non-200 status code. Avoid manually calling JSON.stringify and constructing a Response with the Content-Type header set manually — Response.json() does both correctly in one call.

How do I fetch and parse JSON from an API in Deno?

Deno ships the global fetch() function as a first-class Web API — no import needed. Call const res = await fetch("https://api.example.com/data") and then const data = await res.json() to parse the response body as JSON. For TypeScript type safety, annotate the assignment: const data: MyType = await res.json(). Always check res.ok before calling res.json() — if the server returns a 4xx or 5xx response, res.json() will still succeed (parsing the error body) but the data shape will differ from your expected type. A safe pattern: if (!res.ok) throw new Error(`HTTP ${res.status}`). Deno requires the --allow-net flag to make outbound network requests — this makes network access explicit and auditable. For production code, combine with Zod: const data = MySchema.parse(await res.json()) to validate the shape at runtime, catching API contract changes immediately.

How do I read a JSON file in Deno?

Use JSON.parse(await Deno.readTextFile("config.json")) to read and parse a JSON file. Deno.readTextFile() reads the file as a UTF-8 string asynchronously — no fs module, no require(), no import needed. The --allow-read flag (or --allow-read=./config.json for granular permission) must be present when running the script, otherwise Deno throws a PermissionDenied error. For synchronous reading, use Deno.readTextFileSync() and JSON.parse(). JSON.parse() throws a SyntaxError on malformed JSON — wrap in try/catch when reading externally-provided files. The idiomatic Deno pattern replaces Node.js's fs.promises.readFile() + JSON.parse() or require() for JSON files. In Deno 2.x you can also use the Node.js module: import {'{ readFileSync }'} from "node:fs" for compatibility with shared codebases.

What is Deno KV and how does it store JSON?

Deno KV is a built-in key-value store accessible via Deno.openKv(). It stores any JSON-serializable value natively — objects, arrays, strings, numbers, and booleans — without a manual serialization step. Open the store with const kv = await Deno.openKv() and persist an object with await kv.set(["users", userId], userObject). Read it back with const entry = await kv.get(["users", userId])entry.value is the original typed object. Keys are arrays of strings and numbers forming a hierarchical namespace, enabling range queries with kv.list({'{ prefix: ["users"] }'}) to iterate all users. Deno KV uses SQLite locally and FoundationDB on Deno Deploy, giving it ACID transaction semantics. Use kv.atomic() for multi-key transactions. Values up to 65,536 bytes are supported, and the expireIn option sets a TTL in milliseconds for automatic key expiry — useful for sessions and caches.

How do I stream large JSON files in Deno?

Use @std/encoding/json's JsonParseStream for streaming NDJSON files without loading them into memory. Install with deno add @std/encoding, then import {'{ JsonParseStream }'} from "@std/encoding/json". Open the file with await Deno.open("data.ndjson") and pipe its readable stream through a TextDecoderStream, a TextLineStream (from @std/streams), and finally JsonParseStream. Each line is parsed as an independent JSON value and emitted as a JavaScript object. This pattern processes files of any size in O(1) memory. For HTTP streaming responses, pair ReadableStream with JsonStringifyStream to produce NDJSON output from a Deno.serve() handler. JsonParseStream skips empty lines automatically and throws a SyntaxError on malformed JSON lines — wrap the for await loop in try/catch to handle per-line errors gracefully.

How do I type JSON responses in Deno with TypeScript?

TypeScript does not infer the shape of JSON at runtime — res.json() returns Promise<any> by default. Annotate the receiving variable to get type checking: const user: User = await res.json(). This is a compile-time assertion, not a runtime check. For runtime safety, use Zod: const result = UserSchema.safeParse(await res.json()). Deno ships with TypeScript support built in — no tsc, ts-node, or build step is needed. For Deno KV, annotate the generic parameter: kv.get<User>(["users", id]) returns KvEntryMaybe<User>, which types entry.value as User | null. Deno runs TypeScript directly using the swc compiler under the hood, making single-file type checks 10–100× faster than running tsc. Use deno check file.ts for type checking without executing the file.

How do I write a JSON file in Deno?

Use Deno.writeTextFile("output.json", JSON.stringify(data, null, 2)) to write a pretty-printed JSON file. The --allow-write permission flag is required. For minified output, omit the third argument: JSON.stringify(data). For synchronous writes, use Deno.writeTextFileSync(). To append a JSON object as a new NDJSON line to an existing file, open with Deno.open("log.ndjson", { write: true, append: true, create: true }) and write await file.write(new TextEncoder().encode(JSON.stringify(record) + "\n")). For atomic file replacement (write-then-rename to avoid partial writes), write to a temp file first and rename: await Deno.writeTextFile("output.tmp.json", ...); await Deno.rename("output.tmp.json", "output.json"). This is important for config files where a partial write would corrupt the file. Deno 2.x also supports the Node.js fs module via import {'{ writeFileSync }'} from "node:fs".

How is Deno JSON handling different from Node.js?

Deno uses Web APIs instead of Node.js-specific modules. File I/O uses Deno.readTextFile() instead of fs.readFileSync() or fs.promises.readFile() — no require("fs") or import from the fs module. HTTP uses the standard fetch() and Response.json() instead of http.createServer() and Express's res.json(). Deno requires explicit --allow-read and --allow-write permission flags, making file access intentional and auditable — Node.js grants file access by default. Deno ships TypeScript support built in — no tsc, ts-node, or tsconfig.json needed. Deno uses URL-based imports (or npm: specifiers) instead of package.json and node_modules. Deno 2.x bridges the gap: import {'{ readFileSync }'} from "node:fs" works, and most npm packages run without modification. The key JSON ergonomics difference is Response.json(data) vs Node.js's manual res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify(data)) — Deno's Web API is one call.

Validate and format your JSON instantly

Paste any JSON into Jsonic's online formatter to validate syntax, pretty-print, and inspect nested structures — no install needed.

Open JSON Formatter

Further reading and primary sources