Deno 2.0: TypeScript Without Config, Permission Flags, npm Compatibility and Built-in Tooling

Key takeaways

A Node project usually needs tsconfig, a test runner and a linter before you write code; Deno ships them in one binary and denies file, network and env access unless you grant it. The post shows what that looks like in practice, where npm compatibility helps, and why permission errors appear in CI.

Starting a Node.js project in TypeScript usually means adding tsconfig, a test runner, a linter, and a formatter before writing any code. Deno ships those tools in one binary, runs TypeScript directly, and denies file, network, and environment access unless you grant it. This post goes from a first script through permission flags, the standard library, npm packages, deno.json tasks, testing, CLI binaries, a Hono server, and deployment.

Why Deno Exists

Deno was started by Ryan Dahl, the original author of Node.js, after his 2018 JSConf talk “10 Things I Regret About Node.js”. It is written in Rust and built around a few decisions that differ from Node:

  • Security by default: scripts get no file, network, or environment access unless you pass explicit permission flags
  • TypeScript without setup: .ts files run directly
  • Web-standard APIs: fetch, Request/Response, and WebSocket work the same as in browsers and edge runtimes
  • Single executable: formatter, linter, test runner, and compiler are built in

The npm: specifier arrived in the 1.x line, but Deno 2.0 (October 2024) is the release that made it practical: a much broader Node.js compatibility layer, support for existing package.json files and a local node_modules directory, and deno add/deno install for managing dependencies. Most npm packages can now be imported directly, which is what makes Deno realistic for projects that still depend on the npm ecosystem. For a closer look at how that compatibility works, see Deno 2 npm compatibility.

When to Choose Deno

Deno fits well for:

  • New scripts and CLIs
  • Edge functions on Deno Deploy
  • TypeScript projects where you want to skip build configuration
  • Code where restricting file and network access matters

Be more careful with:

  • Migrating a large existing Node.js application (test thoroughly)
  • Projects that depend on many Node-specific packages or native addons

Deno vs Node vs Bun:

  • Node.js: Most mature runtime with the largest ecosystem
  • Deno: Permission model, built-in TypeScript and tooling, web-standard APIs
  • Bun: Focused on speed and all-in-one tooling with strong Node compatibility

In practice the choice between Deno and Bun is less about raw speed than about defaults. Bun aims to be a drop-in replacement for Node, so existing projects often run unchanged and nothing is restricted. Deno keeps its own conventions — permissions, web APIs first, node: prefixes for built-in modules — and asks you to opt in to Node-isms. If you are starting fresh and value the sandbox, that trade is worth it; if you are moving an existing Express app with dozens of dependencies, Bun or plain Node usually means fewer surprises.


Installation

# macOS / Linux
curl -fsSL https://deno.land/install.sh | sh

# Windows
irm https://deno.land/install.ps1 | iex

# Via Homebrew
brew install deno
deno --version
# deno 2.x.x

Running TypeScript Without Configuration

// hello.ts
const greet = (name: string): string => `Hello, ${name}!`;
console.log(greet("Deno"));
deno run hello.ts
# No tsconfig.json, no compilation step needed

One detail catches almost everyone coming from tsc: deno run does not type-check by default (it has not since Deno 1.23). It strips the types and executes, so const n: number = "oops"; runs happily and only fails if the wrong value breaks something at runtime. Type errors are reported by deno check hello.ts, by deno run --check hello.ts, and by deno test, which does check. The sensible setup is to run deno check in CI (or as part of a deno task) and let the editor’s Deno language server catch errors while you type. If you want to tighten or relax compiler options, they go under compilerOptions in deno.json; strict is already on by default.


Permissions (Secure by Default)

Deno denies all external access by default. You must explicitly grant permissions:

# Network access
deno run --allow-net server.ts

# File read
deno run --allow-read=./data script.ts

# File write
deno run --allow-write=/tmp script.ts

# Environment variables
deno run --allow-env=API_KEY script.ts

# Run subprocesses
deno run --allow-run=git script.ts

# All permissions (use sparingly)
deno run --allow-all script.ts

Scope permissions as narrowly as possible — --allow-net=api.example.com instead of --allow-net.

When a permission is missing in a non-interactive run, the operation throws rather than the process silently misbehaving. In Deno 2 the error looks like NotCapable: Requires net access to "api.example.com:443", run again with the --allow-net flag (older versions threw PermissionDenied). The message names the exact host, path, or variable, which makes it easy to add a scoped flag rather than reaching for -A. Network permissions can include a port (--allow-net=localhost:5432), read/write permissions take comma-separated paths, and Deno 2 also accepts --deny-* flags to carve exceptions out of a broad grant, such as --allow-read --deny-read=./secrets.

Two limits of the model are worth knowing. First, --allow-run is effectively an escape hatch: a subprocess is not sandboxed by Deno, so granting --allow-run=bash is close to granting everything. Second, permissions apply to the whole process, including every npm package you import — the sandbox protects you from a dependency that unexpectedly phones home, but only if you did not already grant blanket --allow-net. That is the concrete security benefit over Node: a compromised package in a script run with --allow-read=./data cannot read ~/.ssh.


Standard Library

Deno ships a comprehensive standard library — no npm needed for common tasks:

// File operations (reading/writing are Deno.* APIs; @std/fs adds helpers)
import { exists } from "jsr:@std/fs";

if (await exists("./config.json")) {
  const config = await Deno.readTextFile("./config.json");
  console.log(JSON.parse(config));
}

// HTTP client
const response = await fetch("https://api.github.com/users/denoland");
const user = await response.json();
console.log(user.login);

// Path utilities
import { join, dirname, basename } from "jsr:@std/path";
const filePath = join(Deno.cwd(), "data", "users.json");

// Date formatting
import { format } from "jsr:@std/datetime";
console.log(format(new Date(), "yyyy-MM-dd"));

The standard library now lives on JSR as separately versioned packages (@std/fs, @std/path, @std/assert, …) rather than the old monolithic https://deno.land/[email protected]/ URLs, which you will still see in older tutorials. Basic file I/O is not in @std/fs at all — Deno.readTextFile, Deno.writeTextFile, and friends are runtime APIs — while @std/fs adds helpers such as exists, ensureDir, copy, walk, and expandGlob. Running this snippet needs --allow-read for the file and --allow-net=api.github.com for the fetch. Bare jsr: imports without a version resolve to the latest release on first run and are then pinned in deno.lock; for anything beyond a throwaway script, declare versions in deno.json (next sections) so upgrades are deliberate.


HTTP Server

// server.ts
Deno.serve({ port: 8080 }, (req: Request) => {
  const url = new URL(req.url);

  if (url.pathname === "/health") {
    return Response.json({ status: "ok" });
  }

  if (url.pathname === "/users" && req.method === "GET") {
    return Response.json([
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" },
    ]);
  }

  return new Response("Not Found", { status: 404 });
});

console.log("Server running on http://localhost:8080");
deno run --allow-net server.ts

Deno’s server uses the standard Web Request/Response API — same as Cloudflare Workers and edge runtimes.

The handler is simply a function from Request to Response (or a promise of one), so there is no framework-specific request object to learn and the same code can be unit-tested by calling the handler with new Request("http://localhost/health"). Deno.serve defaults to port 8000 and already logs Listening on http://0.0.0.0:8080/ when it starts, so the console.log above is redundant. For graceful shutdown, keep the returned server object and call await server.shutdown() on SIGTERM, which matters in containers where the orchestrator sends that signal before killing the process. Routing by hand with if (url.pathname === ...) is fine for a handful of endpoints; once you need path parameters and middleware, use URLPattern or a small framework like Hono (section 9).


npm Compatibility (Deno 2.0)

// Use npm packages with the npm: specifier
import express from "npm:express";
import { z } from "npm:zod";
import chalk from "npm:chalk";

const app = express();

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

app.post("/users", express.json(), (req, res) => {
  const result = UserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.issues });
  }
  res.json({ created: result.data });
});

app.listen(3000);

No npm install needed — Deno downloads packages into a global cache on first run and records exact versions in deno.lock.

Compatibility is broad but not total, and the failures follow patterns. Node built-ins must be imported with the node: prefix (import fs from "node:fs"); bare "fs" works only inside npm packages. Packages that rely on postinstall scripts (common for native addons and tools like Prisma or esbuild) do not run them unless you allow it with --allow-scripts, and packages that expect a physical node_modules folder may need "nodeModulesDir": "auto" in deno.json. Express itself runs, but it also means your server now needs whatever permissions its dependencies touch — --allow-net plus often --allow-env and --allow-read, since many packages read NODE_ENV or files near their own source at startup.

When I first moved a small Express service to Deno, the missing pieces were never TypeScript or syntax; they were the permission prompts triggered by dependencies reading environment variables I did not know about. The prompt tells you which variable is being read, which turns out to be an unexpectedly good audit of what your dependency tree actually does.


Deno.json — Configuration

{
  "tasks": {
    "dev": "deno run --allow-net --allow-read --watch src/server.ts",
    "test": "deno test --allow-net",
    "fmt": "deno fmt",
    "lint": "deno lint"
  },
  "imports": {
    "@std/assert": "jsr:@std/assert@^1",
    "zod": "npm:zod@3",
    "hono": "jsr:@hono/hono@4"
  },
  "lint": {
    "rules": { "tags": ["recommended"] }
  },
  "fmt": {
    "lineWidth": 100,
    "singleQuote": true
  }
}
deno task dev    # run dev server with hot reload
deno task test   # run tests

The imports field is an import map: after declaring "zod": "npm:zod@3", code writes import { z } from "zod" and the version lives in one place instead of being repeated in every file. deno add jsr:@std/path or deno add npm:zod edits this map for you. Tasks play the role of package.json scripts, and putting the permission flags inside them is the main reason to use tasks at all — it makes the required permissions part of the repository instead of tribal knowledge. --watch restarts the process when imported files change; it is a restart, not hot module replacement, so in-memory state is lost on each change.


Testing

// math_test.ts
import { assertEquals, assertThrows } from "jsr:@std/assert";

function divide(a: number, b: number): number {
  if (b === 0) throw new Error("Division by zero");
  return a / b;
}

Deno.test("divide works correctly", () => {
  assertEquals(divide(10, 2), 5);
  assertEquals(divide(9, 3), 3);
});

Deno.test("divide throws on zero", () => {
  assertThrows(() => divide(5, 0), Error, "Division by zero");
});

// Async test
Deno.test("fetch works", async () => {
  const res = await fetch("https://httpbin.org/get");
  assertEquals(res.status, 200);
  await res.body?.cancel(); // release the body, or the resource sanitizer fails the test
});
deno test --allow-net

deno test finds files named *_test.ts, *.test.ts, or test.ts, type-checks them, and runs each Deno.test in isolation. Its sanitizers are the feature people notice first, usually by surprise: a test fails if it leaves an async operation pending, a resource open, or calls Deno.exit. The line await res.body?.cancel() exists because of that — without it, the fetch test fails with a message along the lines of Leaking resources: A fetch response body was created during the test, but not consumed during the test. That strictness is annoying for a day and then saves you from the Node/Jest classic “Jest did not exit one second after the test run has completed”. You can disable a sanitizer per test (sanitizeResources: false) when a library genuinely keeps a connection alive, but treat that as a flag to investigate rather than a default. Tests that hit real network endpoints like httpbin are flaky in CI; for anything important, stub fetch or run a local Deno.serve inside the test.


CLI Tools

Deno excels at building cross-platform CLI tools:

// cli.ts
const args = Deno.args;

if (args.length === 0) {
  console.error("Usage: deno run cli.ts <command>");
  Deno.exit(1);
}

const [command, ...rest] = args;

switch (command) {
  case "hello":
    console.log(`Hello, ${rest[0] || "World"}!`);
    break;
  case "env":
    console.log(Deno.env.toObject());
    break;
  default:
    console.error(`Unknown command: ${command}`);
    Deno.exit(1);
}

Compile to a standalone binary:

deno compile --allow-env --output mycli cli.ts
./mycli hello Deno

Single binary, no runtime needed — works on macOS, Linux, Windows.

“No runtime needed” means the runtime is embedded: the output contains the Deno executable plus your code, so expect a binary in the tens of megabytes even for a ten-line script. The permission flags passed to deno compile are baked in, so mycli above can read environment variables but will throw on network access; decide the permissions at build time. --target cross-compiles (for example --target x86_64-pc-windows-msvc from a Mac), which is the easiest way to ship the same tool to colleagues on all three platforms without asking them to install anything.


Hono — Fast Web Framework

Hono is the recommended web framework for Deno (also works on Cloudflare Workers, Bun, Node):

import { Hono } from "jsr:@hono/hono";

const app = new Hono();

app.get("/", (c) => c.text("Hello Deno!"));

app.get("/users/:id", (c) => {
  const id = c.req.param("id");
  return c.json({ id, name: "Alice" });
});

app.post("/users", async (c) => {
  const body = await c.req.json();
  return c.json({ created: body }, 201);
});

Deno.serve(app.fetch);

The last line is the whole integration: a Hono app exposes a fetch(request) => Response method, which is exactly the handler signature Deno.serve expects. That is why the same app runs on Cloudflare Workers, Bun, and Node (through an adapter) — the framework is written against web standards, not a particular runtime. Compared with the npm Express example above, Hono gives typed route parameters, middleware, and validators without the Node compatibility layer. See the Hono guide for middleware and validation patterns.


Deployment

Deno Deploy (edge, free tier)

# Install deployctl (Deno 2 needs -g for global installs)
deno install -gArf jsr:@deno/deployctl

# Deploy
deployctl deploy --project=my-app server.ts

Deno Deploy runs your code on a global network of edge regions. Its runtime is not a full Deno process: there is no general filesystem write access and subprocesses are not available, so code written for a server with --allow-write or --allow-run needs changes. Deno also offers newer deployment options and a revamped Deploy platform, so check the current documentation for which CLI your project uses; deployctl targets the long-standing Deploy service.

Docker

FROM denoland/deno:2.0.0
WORKDIR /app
COPY deno.json .
COPY src/ ./src/
RUN deno cache src/server.ts
CMD ["deno", "run", "--allow-net", "--allow-env", "src/server.ts"]

The RUN deno cache step downloads and compiles dependencies at build time so the container does not fetch them on every start (in recent Deno 2 releases, deno install --entrypoint src/server.ts does the same job). Copy deno.lock into the image as well, otherwise the build may resolve newer versions than you tested, and pin the image tag to the exact Deno version you run locally. Note that the Docker CMD repeats the permission flags; a deno task start defined in deno.json keeps the container and local runs in sync.


Built-in Tooling

No separate tools to install:

deno fmt          # format (like Prettier)
deno lint         # lint (like ESLint)
deno check        # type-check without running
deno doc          # generate documentation
deno bench        # run benchmarks
deno compile      # bundle to standalone binary
deno jupyter      # Jupyter notebook support

Frequently Asked Questions (FAQ)

Q. My Deno script works locally but fails with a permission error in CI. Why?

A. Deno denies network, file, environment and subprocess access unless you grant it. In an interactive terminal it can prompt you to allow access at runtime, which hides missing flags during local development. In CI or a container there is nobody to answer the prompt, so the call fails. Pass explicit, narrowly scoped flags such as --allow-net=api.example.com and put them in a deno.json task so every environment runs the script with the same permissions.