Deno: The Secure JavaScript Runtime
In 2018, Ryan Dahl — the original creator of Node.js — stood on stage at JSConf EU and gave a talk titled "10 Things I Regret About Node.js." He acknowledged the design mistakes baked into Node from its early days: no security model, a messy module system, callback hell, and a package manager (npm) that had grown into an unwieldy monolith. Two years later, he released Deno — a new JavaScript and TypeScript runtime built from the ground up to fix those regrets. This guide explores everything you need to know about Deno: what makes it different, how to use it, and when it makes sense to reach for it over Node.js.
Deno ships TypeScript support out of the box with no configuration, enforces a permissions-first security model so scripts can't silently read files or make network requests, and uses standard browser-compatible APIs wherever possible. It's built in Rust with Tokio for async I/O, making it fast and memory-safe at the core.
What is Deno and How It Differs from Node.js
Deno is a modern runtime for JavaScript and TypeScript built on V8 (the same engine Chrome and Node.js use), but with a completely different architecture underneath. While Node.js is written in C++ and uses libuv for async I/O, Deno is written in Rust and uses the Tokio async runtime. This gives Deno better memory safety guarantees and a more modern foundation.
At its core, Deno was designed around three principles:
- Security by default: No file, network, or environment access unless explicitly granted
- First-class TypeScript: TypeScript compiles without any configuration or external tools
- Browser compatibility: Uses Web standard APIs like
fetch,URL, andWebSocket
Deno's architecture: code passes through the runtime's permission gate before any system access
Installation and First Steps
Installing Deno is refreshingly simple — it's a single binary with no dependencies. No need for a package.json, no node_modules folder, no separate TypeScript compiler to install.
Install Deno
Install using the official shell script, Homebrew, Winget, or Chocolatey depending on your platform.
# Install via shell script
curl -fsSL https://deno.land/install.sh | sh
# Add to your shell profile (~/.zshrc or ~/.bashrc)
export DENO_INSTALL="$HOME/.deno"
export PATH="$DENO_INSTALL/bin:$PATH"
# Verify installation
deno --version
# deno 2.x.x
# v8 12.x.x
# typescript 5.x.x
# PowerShell (with winget)
winget install DenoLand.Deno
# Or using PowerShell installer
irm https://deno.land/install.ps1 | iex
# Verify
deno --version
# macOS with Homebrew
brew install deno
# Upgrade to latest
brew upgrade deno
# Verify
deno --version
Run Your First Script
Deno runs JavaScript and TypeScript files directly — no compilation step needed.
// TypeScript works natively — no tsconfig.json required
const greet = (name: string): string => {
return `Hello, ${name}! Welcome to Deno.`;
};
console.log(greet("World"));
// Use modern browser APIs
const response = await fetch("https://api.github.com/users/denoland");
const data = await response.json();
console.log(`Deno has ${data.public_repos} public repos on GitHub.`);
# Run the script (--allow-net is required for fetch)
deno run --allow-net hello.ts
# Hello, World! Welcome to Deno.
# Deno has 42 public repos on GitHub.
Explore the REPL
Deno ships with an interactive REPL (Read-Eval-Print Loop) for quick experiments.
$ deno
Deno 2.x.x
exit using ctrl+d, ctrl+c, or close()
> const x: number[] = [1, 2, 3];
undefined
> x.map(n => n * 2)
[ 2, 4, 6 ]
> Deno.version
{ deno: "2.x.x", v8: "12.x.x", typescript: "5.x.x" }
Security by Default
This is arguably Deno's most important feature. By default, a Deno script has zero access to the file system, network, environment variables, subprocess spawning, or high-resolution timers. You must explicitly grant each type of access when running a script. This means a malicious dependency can't silently exfiltrate your SSH keys or phone home to a remote server.
The Permissions System
Permissions are granted via flags when running a script. You can scope them broadly or narrowly:
# Allow all network access
deno run --allow-net script.ts
# Allow network access to specific hosts only
deno run --allow-net=api.example.com,cdn.example.com script.ts
# Allow reading from a specific directory
deno run --allow-read=/home/user/data script.ts
# Allow writing to a specific directory
deno run --allow-write=/tmp script.ts
# Allow reading environment variables
deno run --allow-env=DATABASE_URL,SECRET_KEY script.ts
# Allow running subprocesses (specific commands)
deno run --allow-run=git,npm script.ts
# Allow all permissions (not recommended for production)
deno run --allow-all script.ts
# equivalent shorthand:
deno run -A script.ts
Always grant the minimum permissions your script actually needs. Avoid --allow-all in production. If a dependency is compromised, minimal permissions drastically limit the blast radius of an attack.
What Happens Without Permission
// This will throw if --allow-read is not granted
try {
const text = await Deno.readTextFile("/etc/passwd");
console.log(text);
} catch (err) {
if (err instanceof Deno.errors.PermissionDenied) {
console.error("Permission denied: --allow-read was not granted");
}
}
// You can also check permissions programmatically at runtime
const status = await Deno.permissions.query({
name: "read",
path: "/etc/passwd"
});
console.log(status.state); // "denied" or "granted" or "prompt"
// Request permission interactively at runtime
const result = await Deno.permissions.request({ name: "net" });
if (result.state === "granted") {
// safe to make network calls
}
| Permission Flag | What It Allows | Scope Example |
|---|---|---|
--allow-read |
Read files from disk | --allow-read=./data |
--allow-write |
Write files to disk | --allow-write=/tmp |
--allow-net |
Make network requests | --allow-net=api.stripe.com |
--allow-env |
Access environment variables | --allow-env=PORT,DB_URL |
--allow-run |
Spawn subprocesses | --allow-run=git |
--allow-sys |
Access system info | --allow-sys=hostname |
--allow-hrtime |
High-resolution timers | No scoping available |
--allow-ffi |
Call native libraries | --allow-ffi=./lib.so |
TypeScript as a First-Class Citizen
In Node.js, using TypeScript requires installing typescript as a dependency, creating a tsconfig.json, running tsc to compile, and often using ts-node or tsx for development. Deno eliminates all of that overhead — TypeScript is compiled on the fly using a built-in SWC-based transpiler.
Type Checking on Demand
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user" | "guest";
}
async function fetchUser(id: number): Promise {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json() as Promise;
}
// Generic utility function with TypeScript generics
function groupBy(items: T[], key: keyof T): Record {
return items.reduce((acc, item) => {
const groupKey = String(item[key]);
acc[groupKey] = acc[groupKey] ?? [];
acc[groupKey].push(item);
return acc;
}, {} as Record);
}
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com", role: "admin" },
{ id: 2, name: "Bob", email: "bob@example.com", role: "user" },
{ id: 3, name: "Carol", email: "carol@example.com", role: "user" },
];
const byRole = groupBy(users, "role");
console.log(byRole);
# Run without type checking (fast, uses transpilation only)
deno run typed-example.ts
# Run WITH full type checking (catches type errors)
deno run --check typed-example.ts
# Type check without running
deno check typed-example.ts
# Format code (built-in formatter, like Prettier)
deno fmt typed-example.ts
# Lint code (built-in linter)
deno lint typed-example.ts
Deno caches compiled TypeScript aggressively. The first run compiles and caches; subsequent runs skip compilation entirely. You get TypeScript's safety without paying the compilation cost on every run after the first.
Modern Module System
Node.js uses CommonJS (require) and npm for package management. Deno takes a completely different approach: it uses ESM (ES Modules) exclusively and imports directly from URLs — like browsers do. There is no node_modules folder and no package.json by default.
URL Imports and JSR
// Import from JSR (Deno's modern package registry)
import { assertEquals } from "jsr:@std/assert";
import { join } from "jsr:@std/path";
// Import from npm (Deno has full npm compatibility)
import express from "npm:express@4";
import lodash from "npm:lodash";
// Import from a URL directly (older style, still works)
import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
// Local imports use explicit extensions
import { helper } from "./utils.ts";
import type { Config } from "./types.ts";
// Dynamic imports work too
const module = await import("./heavy-module.ts");
Import Maps for Cleaner Imports
Import maps let you define aliases so you don't repeat full URLs everywhere. This is the recommended approach for larger projects.
{
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.0",
"@std/path": "jsr:@std/path@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.0",
"zod": "npm:zod@^3.22.0",
"hono": "npm:hono@^4.0.0"
},
"tasks": {
"dev": "deno run --watch --allow-net --allow-read main.ts",
"start": "deno run --allow-net --allow-read main.ts",
"test": "deno test --allow-read",
"check": "deno check main.ts",
"lint": "deno lint",
"fmt": "deno fmt"
},
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}
// Clean imports using your deno.json aliases
import { assertEquals } from "@std/assert";
import { join } from "@std/path";
import { z } from "zod";
// Input validation with Zod
const UserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
});
type User = z.infer;
const result = UserSchema.safeParse({
name: "Alice",
email: "alice@example.com",
age: 30,
});
if (result.success) {
const user: User = result.data;
console.log("Valid user:", user);
} else {
console.error("Validation failed:", result.error.issues);
}
Building an HTTP Server
Deno 1.9 introduced Deno.serve() — a stable, high-performance built-in HTTP server. It uses the same Request/Response API as the browser's Fetch API, making it immediately familiar to frontend developers.
Deno's built-in HTTP server uses the standard Web Fetch API for requests and responses
// In-memory "database" for demo purposes
interface Post {
id: number;
title: string;
body: string;
author: string;
createdAt: string;
}
const posts: Post[] = [
{ id: 1, title: "Hello Deno", body: "My first post", author: "Alice", createdAt: "2026-01-01" },
];
let nextId = 2;
// Helper to create JSON responses
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// Route handler
function handler(req: Request): Response | Promise {
const url = new URL(req.url);
const path = url.pathname;
const method = req.method;
// GET /posts — list all posts
if (method === "GET" && path === "/posts") {
return json(posts);
}
// GET /posts/:id — get single post
const postMatch = path.match(/^\/posts\/(\d+)$/);
if (method === "GET" && postMatch) {
const post = posts.find(p => p.id === Number(postMatch[1]));
return post ? json(post) : json({ error: "Not found" }, 404);
}
// POST /posts — create a post
if (method === "POST" && path === "/posts") {
return req.json().then((body: Omit) => {
const post: Post = {
id: nextId++,
title: body.title,
body: body.body,
author: body.author,
createdAt: new Date().toISOString().split("T")[0],
};
posts.push(post);
return json(post, 201);
});
}
// DELETE /posts/:id
if (method === "DELETE" && postMatch) {
const id = Number(postMatch[1]);
const index = posts.findIndex(p => p.id === id);
if (index === -1) return json({ error: "Not found" }, 404);
posts.splice(index, 1);
return json({ message: "Deleted" });
}
return json({ error: "Route not found" }, 404);
}
// Start the server
Deno.serve({ port: 8000 }, handler);
console.log("Server running at http://localhost:8000");
# --watch restarts automatically on file changes (like nodemon)
deno run --watch --allow-net server.ts
# Test the API
curl http://localhost:8000/posts
curl -X POST http://localhost:8000/posts \
-H "Content-Type: application/json" \
-d '{"title":"My Post","body":"Hello!","author":"Bob"}'
curl http://localhost:8000/posts/1
curl -X DELETE http://localhost:8000/posts/1
File System Operations
// Reading a file
const text = await Deno.readTextFile("./data.txt");
console.log(text);
// Writing a file
await Deno.writeTextFile("./output.txt", "Hello from Deno!\n");
// Appending to a file
await Deno.writeTextFile("./log.txt", `${new Date().toISOString()}: event\n`, {
append: true,
});
// Reading a directory
for await (const entry of Deno.readDir(".")) {
if (entry.isFile) {
console.log(`File: ${entry.name}`);
} else if (entry.isDirectory) {
console.log(`Dir: ${entry.name}/`);
}
}
// Check if a file exists
try {
const stat = await Deno.stat("./config.json");
console.log(`File size: ${stat.size} bytes`);
} catch {
console.log("config.json does not exist");
}
// Read JSON files directly
const config = JSON.parse(await Deno.readTextFile("./deno.json"));
console.log(config.tasks);
Deno vs Node.js: Side-by-Side
Understanding where Deno differs from Node.js helps you decide when to use each. They're not enemies — Node.js is battle-tested with a huge ecosystem; Deno is modern, secure, and TypeScript-native. Choose based on your constraints.
| Feature | Node.js | Deno |
|---|---|---|
| Security | Full OS access by default | Sandboxed; explicit permissions required |
| TypeScript | Needs tsc + ts-node or tsx |
Built-in, zero config |
| Module system | CommonJS + ESM (mixed) | ESM only (browser-compatible) |
| Package manager | npm / yarn / pnpm | URL imports + JSR + npm compat |
| package.json | Required | Optional (deno.json instead) |
| node_modules | Required (can be huge) | Not needed by default (global cache) |
| Standard library | Minimal built-ins | Rich @std library via JSR |
| Built-in tools | None (use ESLint, Prettier, etc.) | Formatter, linter, test runner built-in |
| Web APIs | Limited (fetch added in v18) |
Full Web API compatibility |
| Ecosystem maturity | Massive (npm has ~2M packages) | Growing; npm compatibility fills gaps |
| Core language | C++ + libuv | Rust + Tokio (memory-safe) |
| Deployment | Node-compatible hosts everywhere | Deno Deploy, Cloudflare Workers, Fly.io |
Built-in Testing
Deno ships with a test runner so you don't need Jest, Mocha, or Vitest. Write tests directly alongside your code and run them with deno test.
import { assertEquals, assertThrows } from "jsr:@std/assert";
// Simple unit test
Deno.test("add two numbers", () => {
assertEquals(1 + 2, 3);
});
// Async test
Deno.test("fetch returns 200", async () => {
const res = await fetch("https://httpbin.org/status/200");
assertEquals(res.status, 200);
});
// Test with sub-steps
Deno.test("user validation", async (t) => {
await t.step("valid email passes", () => {
const email = "test@example.com";
assertEquals(email.includes("@"), true);
});
await t.step("invalid email fails", () => {
const email = "not-an-email";
assertEquals(email.includes("@"), false);
});
});
// Snapshot testing
Deno.test("snapshot test", async (t) => {
const data = { name: "Alice", role: "admin" };
await assertSnapshot(t, data);
});
# Run all tests in current directory
deno test
# Run tests in a specific file
deno test math_test.ts
# Run with network permission (for async fetch tests)
deno test --allow-net
# Watch mode — re-runs on file changes
deno test --watch
# Generate coverage report
deno test --coverage=./coverage
deno coverage ./coverage
Deno Deploy: Edge Deployment
Deno Deploy is a globally distributed serverless platform that runs your Deno scripts at the edge — in data centers close to your users. It's similar to Cloudflare Workers but built specifically for Deno. Deployments happen in seconds: push to GitHub and your code is live globally.
// This exact file can be deployed to Deno Deploy globally
Deno.serve(async (req: Request) => {
const url = new URL(req.url);
// Serve a dynamic greeting based on query param
if (url.pathname === "/greet") {
const name = url.searchParams.get("name") ?? "World";
return new Response(`Hello, ${name}! From the edge.`, {
headers: { "Content-Type": "text/plain" },
});
}
// Proxy an external API with caching
if (url.pathname === "/github") {
const res = await fetch("https://api.github.com/repos/denoland/deno", {
headers: { "User-Agent": "Deno Deploy" },
});
const data = await res.json();
return Response.json({
stars: data.stargazers_count,
forks: data.forks_count,
description: data.description,
});
}
return new Response("Not Found", { status: 404 });
});
Deno CLI Commands Quick Reference
| Command | Description |
|---|---|
deno run file.ts |
Run a script |
deno run --watch file.ts |
Run with auto-reload on changes |
deno check file.ts |
Type-check without running |
deno test |
Run all tests in current directory |
deno fmt |
Format code (built-in Prettier-like) |
deno lint |
Lint code for common errors |
deno doc file.ts |
Generate documentation from JSDoc |
deno compile file.ts |
Compile to a standalone executable |
deno bundle file.ts |
Bundle script and dependencies |
deno info file.ts |
Show dependency tree |
deno cache file.ts |
Pre-cache dependencies |
deno upgrade |
Upgrade Deno to latest version |
When to Choose Deno
Deno isn't a replacement for Node.js in every context — it's a thoughtfully redesigned runtime that shines in specific scenarios. Here's a practical guide for making the decision:
Choose Deno When...
- Security matters most: Running untrusted code or scripts with access to sensitive files
- TypeScript is your default: No more compilation setup — just write
.tsfiles - Building edge functions: Deno Deploy gives sub-10ms cold starts globally
- Starting fresh: New projects benefit from Deno's clean, modern defaults
- Tooling fatigue: Built-in formatter, linter, and test runner reduce setup overhead
- Browser code sharing: Code that works in Deno often works in browsers without modification
Stick with Node.js When...
- Ecosystem depth is critical: You rely on npm packages that don't have Deno equivalents
- Team expertise: Your team knows Node.js deeply and migration costs are high
- Legacy codebase: Existing Node.js applications don't need to migrate
- Hosting constraints: Your deployment target only supports Node.js
- Framework choice: You want Express, Fastify, or NestJS exactly as-is
"The goal with Deno is not to replace Node.js, but to provide a better environment for server-side JavaScript going forward — one that doesn't carry the technical debt of the past."
— Ryan Dahl, Creator of Deno
Deno 2.0 brought full npm compatibility, meaning you can now use the vast majority of npm packages in Deno without any friction. The gap between Deno and Node.js has narrowed significantly, and Deno's unique strengths — security, TypeScript-first, Web APIs, zero-config tooling — make it a compelling choice for modern server-side JavaScript development. Whether you adopt it for new projects, edge deployments, or scripting, Deno rewards developers who value clean defaults and modern design principles.