Tools

Deno: The Secure JavaScript Runtime

Mayur Dabhi
Mayur Dabhi
June 12, 2026
14 min read

You want to write a 40-line TypeScript script that hits an API and writes a file. In Node.js that means a package.json, installing typescript and tsx, a tsconfig.json, and a node_modules folder bigger than the script. And every package you pull in can read your home directory and open network connections without asking.

That friction is a big part of why Deno exists. Ryan Dahl, who created Node.js, gave a talk at JSConf EU in 2018 called "10 Things I Regret About Node.js" (no security model, the module system, the centralised npm setup) and released Deno in 2020 to address them. This post covers what's different about Deno, how to install and use it, the permission system, modules, a small HTTP server, testing, and when I'd pick it over Node.

Why Deno?

You get TypeScript with no config, scripts that can't touch files or the network unless you allow it, and the same fetch/Request/Response APIs you already use in the browser. The runtime itself is written in Rust on top of Tokio.

What is Deno and How It Differs from Node.js

Under the hood Deno and Node.js share the V8 engine, so your JavaScript runs the same way. What differs is everything around V8. Node.js is written in C++ and uses libuv for async I/O; Deno is written in Rust and uses Tokio. That gives Deno's own code stronger memory safety guarantees, though for day-to-day app work you'll notice the defaults far more than the implementation language.

Deno was designed around three ideas:

JS / TS Code your script Deno Runtime TypeScript Compiler Permissions Check Web Standard APIs Rust + Tokio V8 Engine Executes JavaScript OS / System File, Network, Env Permissions Gate execute system calls

Deno's architecture: code passes through the runtime's permission gate before any system access

Installation and First Steps

Deno is a single binary with no dependencies. There's no package.json to create, no node_modules folder, and no separate TypeScript compiler to install.

1

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
2

Run Your First Script

Deno runs JavaScript and TypeScript files directly. You don't run a separate compile step.

hello.ts
// 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.`);
Terminal
# 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.
3

Explore the REPL

Deno ships with an interactive REPL (Read-Eval-Print Loop) for quick experiments.

Terminal: REPL
$ 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 the feature I'd point to first. Out of the box, a Deno script has no access to the file system, network, environment variables, or subprocesses. You grant each kind of access when you run it. So a compromised dependency can't quietly read your SSH keys or send data to some remote server unless you've allowed that.

The Permissions System

You grant permissions with flags on the command line, either broadly or scoped to specific hosts, paths, or names:

Permission Flags
# 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
Least Privilege Principle

Grant only what the script needs and keep --allow-all out of production. If a dependency does get compromised, narrow permissions limit what it can reach.

What Happens Without Permission

Run a script that reads a file without --allow-read in your terminal and Deno prompts you interactively. In CI, Docker, or under systemd there's no TTY, so there's no prompt: the script just fails with something like NotCapable: Requires read access to "/etc/passwd", run again with the --allow-read flag (older versions throw PermissionDenied with the same message). This catches a lot of people the first time they containerise a Deno app that worked fine locally, because they'd been clicking "allow" in the prompt without noticing. Put the exact flags in your deno.json tasks or Dockerfile CMD so local and production runs match. One more gotcha: the demo below checks Deno.errors.PermissionDenied, which is what Deno 1.x threw. On Deno 2 you'll want to check Deno.errors.NotCapable as well, or the catch branch won't match.

permissions-demo.ts
// 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, TypeScript usually means installing typescript, creating a tsconfig.json, running tsc, and adding ts-node or tsx for development. Deno skips all of that. It strips types on the fly with a built-in SWC-based transpiler. Note that a plain deno run doesn't type check by default, so type errors won't stop the script. Use --check or deno check (in CI especially) if you want them caught.

Type Checking on Demand

typed-example.ts
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);
Terminal
# 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
Performance Note

Deno caches transpiled output and downloaded dependencies. The first run does the work; later runs reuse the cache until the source changes. In Docker, run deno cache main.ts (or deno install) in a build step so containers don't download dependencies on startup.

Modern Module System

Node.js uses CommonJS (require) and npm for package management. Deno uses ES Modules only and can import straight from URLs, the way browsers do. It also understands jsr: and npm: specifiers. By default there's no node_modules folder and no package.json.

URL Imports and JSR

modules-demo.ts
// 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 specifiers and versions in every file. Anything beyond a single script should have one, since it also gives you one place to bump versions.

deno.json
{
  "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
  }
}
main.ts (after setting up deno.json)
// 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.serve() is the built-in HTTP server, stable since Deno 1.35. Your handler takes a standard Request and returns a Response, the same objects you use with fetch in the browser, so there's very little new API to learn.

Client Browser / curl Deno.serve() Request (Web API) Handler function Response (Web API) Your Handler Route logic DB / file / API calls here Response JSON / HTML / etc

Deno's built-in HTTP server uses the standard Web Fetch API for requests and responses

server.ts: REST API with Deno.serve()
// 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");
Terminal: run the server
# --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

file-ops.ts
// 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

This isn't a winner-takes-all choice. Node.js has a huge ecosystem and over a decade of production use. Deno has safer defaults and less setup. Which one fits depends on your constraints more than on the feature list.

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 (millions of npm 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.

math_test.ts
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);
});
Terminal
# 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 Deno's own serverless platform. It runs your scripts in data centers close to your users, much like Cloudflare Workers, and you can connect a GitHub repo so every push deploys. Keep in mind it's not a long-running server: there's no local disk you can rely on, and in-memory state like the posts array above won't survive between isolates.

edge-function.ts (runs on Deno Deploy)
// 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 I'd Use Deno (and When I Wouldn't)

Deno doesn't replace Node.js everywhere. It fits some kinds of work much better than others:

Choose Deno When...

  • You run code you don't fully trust: Scripts or dependencies near sensitive files or credentials
  • TypeScript is your default: You just write .ts files, with no build setup
  • You're building edge functions: Deno Deploy starts isolates quickly and the code is plain Web APIs
  • It's a new project: No migration cost, and you start with sensible defaults
  • You're tired of tooling config: Formatter, linter, and test runner come with the binary
  • You share code with the browser: Code built on Web APIs often runs in both without changes

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

Deno 2 made npm compatibility good enough that most pure JavaScript packages just work through npm: specifiers. "Most" is doing some work in that sentence, though. Packages with native addons, postinstall scripts, or deep assumptions about node_modules layout are where you'll hit trouble, and you only find out when you try. Before committing a real project, I'd spike the three or four dependencies you can't live without and run them under Deno first.

For my own work, Deno is the easy pick for one-off scripts, small internal APIs, and edge functions, where the permission flags and zero setup pay off immediately. For an existing Node.js app with a big dependency tree and a team that knows it well, I wouldn't migrate just for the cleaner defaults. The payoff rarely covers the time spent chasing compatibility edge cases.

Deno JavaScript TypeScript Runtime Security Node.js Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.