Everything I know about Deno

45 min read

12197 words

Hey and welcome everyone to this guide on Deno! I'm so excited to have you here. In this guide, we're going to dive deep into Deno, exploring its features, APIs, and tools. By the end of this guide, you'll have a solid understanding of Deno and be ready to start building your own projects with it.

This guide is a living document, which means I'll be updating it regularly with new content and examples. I'm here to help you learn and answer any questions you might have. So, let's get started!

If you're interested in being notified about new parts or in general about things I do and write about, head over to my newsletter and subscribe. I would really appreaciate it!

Topics that will be added to this guide:

What is Deno

Let’s start with the official definition of the Deno website and break it up afterwards:

Deno is the open-source JavaScript runtime for the modern web. Built on web standards with zero-config TypeScript, unmatched security, and a complete built-in toolchain, Deno is the easiest, most productive way to JavaScript.

At its heart, Deno is a runtime that lets us run JavaScript, much like Node.js if you're familiar with that. But here's where it gets interesting: Deno is built with TypeScript in mind. We'll be using TypeScript a lot in this guide, and I'll show you why that's actually a good thing, even if you're more comfortable with plain JavaScript.

Now, I know what you might be thinking: "Great, another JavaScript runtime to learn." But trust me, Deno has some tricks up its sleeve that make it really stand out. We're going to talk about its approach to security, which is pretty cool. It uses these things called runtime flags that give you more control over what your code can do.

And here's something I'm personally excited about - Deno comes with a bunch of built-in tools that make our lives as developers so much easier. No more spending hours setting up your development environment!

Throughout this guide, we'll explore all of these aspects of Deno. I'm here to guide you, answer your questions, and hopefully make the learning process enjoyable. So, ready to start this Deno journey with me?

How is this Guide structured

I've designed this Deno guide to be your fast track into the world of Deno, focusing on the APIs and tools you'll use most often in your projects.

My goal? To get you up and running with Deno as quickly as possible. I've distilled the most important topics into a concise package, giving you everything you need to get a solid grasp of Deno. Think of it as an 80/20 approach (Pareto Principle) - we're covering the 80% of Deno that you'll use most of the time. If you want to know more about this type of approach, check out the Pareto principle article on Wikipedia.

Before we dive in, let's talk prerequisites. You'll need:

  1. A good foundation in JavaScript
  2. Ideally, some experience with TypeScript

Don't worry if you're not quite there yet! If you need to brush up on your JavaScript, we highly recommend the JavaScript course at freeCodeCamp.org. For TypeScript, the official TypeScript documentation is an excellent starting point.

Once you've got those basics down, you're all set to jump into Deno with us!

I've built this guide drawing from multiple sources:

  • The official Deno documentation
  • Various supporting websites
  • Open-source projects
  • My personal experiences with Deno

Throughout the guide, I'll provide plenty of in-depth resources for each topic. These are there to help you dive deeper into areas that particularly interest you or that you find challenging.

Ready to get your feet wet with Deno? Let's dive in!

Install deno and set it up

Let's get Deno up and running on your machine! The installation process is straightforward, and I'll walk you through it step by step for different platforms.

macOS / Linux

For macOS and Linux users, it's as simple as running a shell script. Here's how you do it:

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

This script automatically detects your system and installs the appropriate Deno binary.

🚨 Safety First: Before running any script from the internet, it's crucial to review its contents. Even if it's from a trusted source like Deno's official website, it's a good habit to verify what you're executing on your system.

Homebrew

It is also possible to install Deno via homebrew. It is as simple as the script above:

brew install deno

πŸ’‘ Note: You have to note that upgrading Deno via homebrew is not done with the deno upgrade command. Instead you have to use brew upgrade deno.

Windows

Windows users, you're not left out! The process is just as straightforward:

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

This PowerShell command downloads and runs the Deno installation script tailored for Windows.

Docker

For the Docker enthusiasts out there, Deno offers a variety of official images. These are based on popular distributions, giving you flexibility in your containerized environments.

Here's a quick reference of available Docker images:

Base Docker Tag
Alpine Linux denoland/deno:alpine
Debian denoland/deno:debian (default)
Distroless denoland/deno:distroless
Ubuntu denoland/deno:ubuntu
Binary only denoland/deno:bin

For more details on Dockerfiles and usage, check out the Deno Docker GitHub repository.

Need More Info?

If you want to dive deeper into installation options or troubleshoot any issues, the official Deno installation guide is an excellent resource. There you’ll find alternative installations as well.

Now that we've covered installation, are you ready to start building with Deno? Let's move on to your first Deno project!

Setting Up Your Deno Development Environment

One of Deno's great features is its built-in language server (LSP), making it compatible with a wide range of editors. While we'll focus on three popular options here, remember that there are many more. For a comprehensive list, check out the official Deno docs on Setting Up Your Environment.

VSCode users, you're in luck! There's an official Deno extension waiting for you in the Visual Studio Marketplace.

Here's how to set it up:

  1. Install the extension from the marketplace.
  2. Open the command palette (Cmd/Ctrl + Shift + P).
  3. Type and select "Deno: Initialize Workspace Configuration".
  4. Answer a few quick questions, and you're done!

This creates a configuration file in the .vscode subdirectory, perfect for switching between Deno, Bun and Node.js projects, as the settings are only valid for this workspace.

JetBrains IDEs: For the IntelliJ Lovers

If you're a fan of JetBrains IDEs, you're covered. The official Deno extension is available in the JetBrains Marketplace.

Setup is straightforward:

  1. Install the extension in your IDE.
  2. Settings should auto-configure, but you might need to tweak a few things.

For a detailed guide, check out the WebStorm Blog post on Deno support.

Zed: The New Kid on the Block

Zed users, don't feel left out! While the setup might feel a bit trickier, it's definitely doable.

Here's your game plan:

  1. Open the command palette and search for "zed: extensions".
  2. Find and install the community-built Deno extension.
  3. Create a .zed/settings.json file in your workspace with the following content:
{
  "languages": {
    "TypeScript": {
      "language_servers": [
        "deno",
        "!typescript-language-server",
        "!vtsls",
        "!eslint"
      ]
    },
    "TSX": {
      "language_servers": [
        "deno",
        "!typescript-language-server",
        "!vtsls",
        "!eslint"
      ]
    }
  }
}

This configuration tells Zed to use Deno's LSP instead of the standard TypeScript ones. Need more help? I've got a detailed guide on setting up the Deno language server in Zed that I keep updated.

Remember, whichever editor you choose, the goal is the same: a smooth, efficient Deno development experience. Happy coding and now let’s set up our project and start right in.

The Deno Toolchain

The deno init Command

Similar to Node.js projects where we get a project going with e.g. npm init, Deno provides an init command to set up the essential files in your directory. Here's how you can use it:

deno init my-deno-project

When run without arguments (deno init), it creates three default files in your current directory. If you provide an additional argument (like my-deno-project in the example above), Deno will create a folder with that name and place these files inside.

The Three Default Files

Let's see what Deno creates for us:

  1. main.ts - This is the entry point of your app, similar to index.js or app.js in Node.js projects. It's where your application's execution begins. However, you're not restricted to this name and can choose freely.

  2. main_test.ts - As the _test part suggests, this is where your tests live. Deno has built-in testing capabilities, which we'll cover in more detail later. This file contains a basic test for the functionality in main.ts. It's good to give the test files the same name as the file that is tested.

  3. deno.json - This is the configuration file for a Deno project, similar to package.json in Node.js, but with key differences. Here, you can configure various aspects of your project:

    • Import maps
      • Scripts
      • Formatter settings
      • Linter rules
      • TypeScript compiler options

    The deno.json file is central to customizing your Deno development environment.

Running a Deno Project

Once we've initialized our project, we can run it using the deno run command:

deno run main.ts

Give the files created by default this command will execute our main.ts file with Add 2 + 3 = 5 printed to the console.

One key difference from Node.js is that Deno doesn't require a package manager or a node_modules folder. All dependencies are fetched and cached on first run, making project setup much simpler. However if we would like to use a npm package this possible. How it's done is described in the next chapter about using external modules in Deno.

πŸ“‹ Further Documentation:

Working with External Modules in Deno

Deno has a unique and refreshing approach to including modules and external code. This approach simplifies dependency management and makes your projects more portable.

URL-based Imports

One of the standout features of Deno is its ability to import modules directly from URLs. You can pull code straight from GitHub, or any other source that serves JavaScript or TypeScript files, without needing to install it first. Here's an example:

import { format } from "https://raw.githubusercontent.com/denoland/std/main/fmt/duration.ts";
console.log(format(99674, { style: "digital" }));

In this example, we're importing the format function directly from Deno's standard library hosted in raw format on github.com. When running this script the output should be 00:00:01:39:674:000:000. There we can see, that we could work without a package management system after all.

Caching Mechanism

Similar to tools like yarn or pnpm Deno uses a local cache of all the dependencies it has to download. Deno will download and cache modules the first time you run your script or application. Therefore we need to download them once and are able to use them in every project without needing a redownload.

When Deno encounters a URL import, it follows these steps:

  1. Check if the module is in the local cache.
  2. If not, download the module and store it in the cache.
  3. Use the cached version for subsequent runs.

This caching mechanism ensures that your code runs consistently and doesn't unnecessarily re-download modules. The cache is stored in a directory that respects your operating system's conventions:

  • On Linux/macOS: $HOME/.cache/deno/
  • On Windows: %LOCALAPPDATA%\deno\ (usually C:\Users\{UserName}\AppData\Local\deno\)

If you can't find a directory there, try using deno cache which should give you a similar output like the following:

❯ deno info
DENO_DIR location: /Users/user/Library/Caches/deno
Remote modules cache: /Users/user/Library/Caches/deno/deps
npm modules cache: /Users/user/Library/Caches/deno/npm
Emitted modules cache: /Users/user/Library/Caches/deno/gen
Language server registries cache: /Users/user/Library/Caches/deno/registries
Origin storage: /Users/user/Library/Caches/deno/location_data

There we can see that my remote modules cache can be found under /Users/user/Library/Caches/deno/deps. If we take a look on the contents of the directory, we see how Deno manages those remote modules:

➜ ls /Users/user/Library/Caches/deno/deps/https/
cdn.jsdelivr.net          examples.deno.land        jsr.io
cdn.sheetjs.com           fresh.deno.dev            raw.githubusercontent.com
deno.land                 github.com                unpkg.com
esm.sh

Inside of those directories we can find a lot of different files that represent our JavaScript or Typescript files. If we take a look into one of them we can see that it is a 1:1 copy. For example looking into /Users/user/Library/Caches/deno/deps/https/raw.githubusercontent.com/ we might find our duration.ts file used earlier. However the file name is in this case a hash so that Deno can be sure that it knows the file or has to download it.

As you might have read that we have a "npm modules cache" there. We will learn other potential sources of modules in a later chapter. You can be sure, that those will be stored in the cache in the same way.

Security Considerations

While URL imports are powerful, they do raise security concerns. What if the content at a URL changes or becomes malicious? Deno addresses this with several features:

  1. Lockfile: You can generate a lockfile (deno.lock) that records the hashes of your dependencies. Deno will verify these hashes on subsequent runs. You can use deno cache --lock=deno.lock <file> to do this manually.

  2. Integrity Checking: You can specify a hash in your import statement to ensure you're getting the exact file you expect:

    import { serve } from "https://raw.githubusercontent.com/denoland/std/main/fmt/duration.ts#sha256=abcdef...";
    
  3. Permissions Model: Deno has a robust permissions model that we'll cover in more detail later. It prevents scripts from accessing resources (like the network or file system) without explicit permission.

Using npm Modules

Deno isn't limited to URL-based modules. It can use npm modules directly, bridging the gap between the Deno and Node.js ecosystems. To use an npm module, you use the npm: specifier in front of the module name. For example:

import express from "npm:express";

const app = express();

app.get("/", function (req, res) {
  res.send("Hello World");
});

app.listen(3000);

Running the script in Deno will result in setting up a simple HTTP server with express. This feature allows you to leverage the vast npm ecosystem while enjoying the benefits of Deno's modern runtime.

If you're trying this example, you'll see similar prompts to this:

❯ deno run main.ts -A
┏ ⚠️  Deno requests read access to <CWD>.
┠─ Requested by `Deno.cwd()` API.
┠─ Learn more at: https://docs.deno.com/go/--allow-read
┠─ Run again with --allow-read to bypass this prompt.
β”— Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all read permissions) >

This is the implementation of Deno's security model. We have to explicitly give Deno our permission to use different APIs. The message gives you a lot of hints on where to look for more information. We'll cover the permission model in detail in our Deno on the CLI chapter.

Managing Dependencies

Unlike Node.js with its package.json, Deno doesn't require a central file listing all dependencies. Instead, dependencies are specified directly in your code through imports. This approach has several advantages:

  1. Clear Dependency Tree: You can easily see which file is using which dependency.
  2. No "Hidden" Dependencies: All dependencies are explicitly imported where they're used.
  3. Easy to Remove Unused Dependencies: If you remove an import, you've removed the dependency.

However, if you want to manage versions of your dependencies centrally, you can use import maps. These allow you to specify versions for your imports in a JSON file, similar to package.json. Here's an example:

{
  "imports": {
    "colors": "https://raw.githubusercontent.com/denoland/std/main/fmt/colors.ts"
  }
}

With this import map, you can simply use import { bold } from "colors"; in your code, and Deno will use the version specified in the import map.

By default Deno uses a deno.json file with the imports attribute as a default way to manage imported modules.

Tradeoffs and what the HTTP imports might bring into your projects Did Ryan Dahl summarize in a blog post.

πŸ“‹ Further Documentation

JavaScript's (Not) Missing Standard Library

One of the long-standing criticisms of JavaScript has been its lack of a comprehensive standard library that doesn't rely on external dependencies. Deno addresses this issue head-on by providing a well-crafted standard library maintained by its core developers and community contributors.

The Deno Standard Library

Deno's standard library, referred to as std, is a collection of high-quality, well-tested modules that cover a wide range of functionalities. These modules are designed to work seamlessly with Deno and follow its design principles. A lot of the modules work in NodeJS, Bun or even in the browser.

Key aspects of the Deno standard library:

  1. No External Dependencies: All modules in the standard library are implemented without relying on external packages.

  2. TypeScript First: The entire library is written in TypeScript, providing excellent type checking and autocompletion in supported editors.

  3. Browser APIs: Instead of using custom implementations the library focuses on implementing features with standard Web APIs to ensure compatibility with other runtimes like Bun or Node.

  4. Modular Design: Each module is small and focused, allowing you to import only what you need.

  5. Continuous Improvement: The library is actively maintained and improved by the Deno core team and community contributors.

Example Modules in the Standard Library

Let's explore a couple of modules in Deno's standard library:

  1. fs: File system operations

    import { readJson, writeJson } from "https://deno.land/std/fs/mod.ts";
    
    const data = await readJson("config.json");
    await writeJson("output.json", data);
    
  2. path: Path manipulation utilities

    import { join } from "https://deno.land/std/path/mod.ts";
    
    const fullPath = join("directory", "subdirectory", "file.txt");
    
  3. http: HTTP server and client utilities

    import { serve } from "https://deno.land/std/http/server.ts";
    
    serve((req) => new Response("Hello, World!"));
    
  4. datetime: Date and time utilities

    import { format } from "https://deno.land/std/datetime/mod.ts";
    
    console.log(format(new Date(), "yyyy-MM-dd"));
    
  5. testing: Assertion library for Deno's built-in test runner

    import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
    
    Deno.test("example test", () => {
      assertEquals(2 + 2, 4);
    });
    

Benefits of Using the Standard Library

Using Deno's standard library offers several advantages:

  1. Consistency: The modules are designed to work together seamlessly.
  2. Performance: They're optimized for use with Deno. (However they work in other runtimes)
  3. Built-in TypeScript Support: No need for separate @types packages.

Versioning and Stability

It's worth noting that the standard library is versioned independently of Deno itself. When importing from the standard library, you specify a version:

import { serve } from "https://deno.land/std@0.140.0/http/server.ts";

This allows you to lock to a specific version of the standard library, ensuring consistency across different environments and over time.

Since importing standard library modules via the "deno.land" url is deprecated, we will now talk about how we import them correctly.

πŸ“‹ Further Documentation

JSR - JavaScript Registry

JSR, or JavaScript Registry, is an exciting new project initiated by the Deno team. It aims to provide a modern, TypeScript-first package registry that addresses the limitations of npm while being usable across different JavaScript runtimes.

Key Features of JSR

  1. TypeScript-First: JSR is designed with TypeScript in mind, making it easier to publish and consume TypeScript packages.

  2. Cross-Runtime Compatibility: Packages on JSR can be used not only in Deno, but in Node.js, browsers, and other JavaScript environments.

  3. Integrated Documentation: JSR automatically generates documentation from the published modules, making it easier for users to understand and use the packages.

  4. Transparency: Package contents are visible directly on the JSR website, increasing trust and making it easier to audit dependencies.

  5. Quality Scoring: JSR provides a scoring system that indicates the quality of a package based on factors like documentation, compatibility, and typing.

  6. Dependency Information: Clear visibility of both dependencies and reverse dependencies (packages that depend on a given package).

Using JSR in Deno

Using a package from JSR in your Deno project is straightforward:

import { format } from "jsr:@std/fmt/duration";

console.log(format(99674, { style: "digital" }));

Using the first example we did here, we can substitute the long github.com link with jsr:@std/fmt/duration - Deno does the rest for us. Downloading, caching and providing the duration package for us.

JSR vs npm

While JSR isn't meant to replace npm, it offers several advantages:

  1. TypeScript-Native: No need for separate @types packages or compilation steps.
  2. Simpler Publishing Process: No need for a separate build step before publishing.
  3. Better Transparency: Easy to see what's in a package before using it.
  4. Cross-Runtime by Design: Packages are designed to work across different JavaScript environments.

The Future of JSR

As of now, JSR is in its early stages, but it shows great promise. The Deno team plans to make it a community-driven project, with governance that involves the broader JavaScript community. This approach aims to ensure that JSR evolves to meet the needs of the entire JavaScript ecosystem, not only Deno users.

fmt

Deno's fmt command is your go-to tool for code formatting. It's inspired by tools like Go's go fmt command, aiming to provide a standardized and opinionated way to format your code.

Under the hood

Deno uses a Rust-based formatter called dprint. This ensures fast and consistent formatting across your project.

What can it format?

Deno's formatter supports all the file types you'll commonly use in a Deno project:

  • JavaScript (.js) and TypeScript (.ts)
  • JSX (.jsx) and TSX (.tsx)
  • Markdown (.md, .markdown)
  • JSON (.json) and JSON with comments (.jsonc)

πŸ’‘ Since Deno 1.46 we have support for HTML, CSS, SCSS, Sass, Less, YAML, Astro, Angular, Svelte and Vue files as well. The formatter for those file types is in an unstable state for now. Update: Since Deno 2.0.0-rc.10 the formatter for those file types is stable. You can find the PR #25753 here. This Deno version also adds support for .vto and .njk files. Which are often used in the lume static generator.

Ignoring formatting

Sometimes you might want to keep a specific piece of code formatted in a certain way. Deno allows for this:

  • To ignore formatting for a specific line:

    // deno-fmt-ignore
    const messyButIntentionalCode = doSomethingComplicated( );
    
  • To ignore an entire file:

    // deno-fmt-ignore-file
    

Customizing the formatter

You can tailor the formatter to your project's needs by adding a fmt section to your deno.json or deno.jsonc file:

{
  "fmt": {
    "useTabs": true,
    "lineWidth": 80,
    "indentWidth": 4,
    "semiColons": true,
    "singleQuote": true,
    "proseWrap": "preserve",
    "include": ["src/"],
    "exclude": ["src/testdata/", "data/fixtures/**/*.ts"]
  }
}

This configuration allows you to:

  • Use tabs instead of spaces
  • Set a maximum line width
  • Define indentation width
  • Choose whether to use semicolons
  • Prefer single quotes over double quotes
  • Control how markdown prose is wrapped
  • Specify which directories to include or exclude from formatting

By setting up your formatting rules, you ensure that your entire team is working with a consistent code style, which can improve readability and reduce merge conflicts.

πŸ“‹ Further Documentation

lint

Deno comes with its own linter, accessible via the deno lint command. This tool goes beyond formatting your code - it helps ensure consistent coding practices across your project. Think of it as your personal code quality assistant. It behaves similar to popular tools like eslint

What does the Deno linter do?

The Deno linter checks for a variety of potential issues in your code. Examples are:

  • Consistent use of quotation marks (single vs. double)
  • Proper usage of const instead of var or let when variables aren't reassigned
  • And many more code quality checks

In total, Deno provides 104 linting rules as of this writing. Out of these, 76 are recommended and enabled by default. This means you get a solid foundation for code quality right out of the box.

Configuring the linter

Like many Deno tools, you can configure the linter in your deno.json or deno.jsonc file. This allows you to set project-wide linting preferences.

Taken from the official documentation is the following example to include or exclude rules or files:\

{
  "lint": {
    "include": ["src/"],
    "exclude": ["src/testdata/", "src/fixtures/**/*.ts"],
    "rules": {
      "tags": ["recommended"],
      "include": ["ban-untagged-todo"],
      "exclude": ["no-unused-vars"]
    }
  }
}

For a comprehensive list of all available lint rules, check out the deno_lint documentation.

Ignoring linter rules

Sometimes, you might need to bypass certain linter rules. Deno makes this easy:

  1. To ignore linting for an entire file:

    // deno-lint-ignore-file
    
  2. To ignore multiple rules for a file:

    // deno-lint-ignore-file no-explicit-any no-empty
    
  3. To ignore a rule for a specific line:

    // deno-lint-ignore no-explicit-any
    const myVar: any = getSomeValue();
    

Here's a neat feature: you can add comments explaining why you're ignoring a rule:

// deno-lint-ignore no-explicit-any -- we really need to use 'any' here due to [reason]
const complexData: any = fetchDataFromExternalAPI();

This is great for maintaining code clarity, when working in teams.

πŸ“‹ Further Documentation

doc

Documentation is crucial for any project, and Deno makes it easy to generate comprehensive documentation for your code.

How it works

Deno's documentation generator works by reading the JSDoc comments in your code. JSDoc is a markup language used to annotate JavaScript source code files. When you run deno doc, it parses these comments and generates documentation based on them.

Basic usage

To generate documentation for a file, run:

deno doc your_file.ts

This will output the documentation to the console. However, there are more useful ways to use this feature.

Generating HTML documentation

You can create a static HTML website of your documentation:

deno doc --html --name="My Project" main.ts

This command:

  • Uses the --html flag to generate HTML output
  • Sets the name of your project with --name
  • Specifies the entry point of your project (main.ts)

By default, this creates a docs folder in your current directory with the generated HTML files.

JSON output

If you prefer machine-readable output, you can generate JSON:

deno doc --json main.ts

This is useful if you want to process the documentation data programmatically.

Linting your documentation

Deno can help ensure your documentation is complete. Use the --lint flag:

deno doc --lint main.ts

This will warn you about missing documentation or inconsistencies.

For example:

error[missing-jsdoc]: exported symbol is missing JSDoc documentation
 --> /Users/user/code/main.ts:8:14
  |
8 | export const myFunc = (str: string) => {
  |              ^


error: Found 1 documentation lint error.

Read more about what the --lint finds in the official documentation

Best practices for documentation

  1. Document all exported symbols (functions, classes, interfaces, etc.).
  2. Provide clear, concise descriptions for each item.
  3. Include examples where appropriate.
  4. Specify types for parameters and return values.

Here's an example of well-documented code:

/**
 * Represents a user in the system.
 */
export interface User {
  /**
   * The user's full name.
   */
  name: string;

  /**
   * Unique identifier for the user.
   */
  id: number;
}

/**
 * Retrieves the username from a User object.
 * @param {User} user - The user object.
 * @returns {string} The user's name.
 * @example
 * const user = { name: "John Doe", id: 1 };
 * const name = getUsername(user);
 * console.log(name); // Outputs: "John Doe"
 */
export function getUsername(user: User): string {
  return user.name;
}

By following these practices and using Deno's documentation tools, you can ensure your project is well-documented and easy for others to understand and use.

πŸ“‹ Further Documentation

test & coverage

Testing is a crucial part of any software development process, and Deno makes it incredibly easy to get started. With Deno's built-in testing suite, you have no excuse not to test your code!

Writing your first test

Let's start with a simple example. Say we have a function that adds two numbers (like the function that we get after running deno init):

// main.ts
export function add(a: number, b: number): number {
  return a + b;
}

We can write a test for this function like so:

// main_test.ts
import { assertEquals } from "jsr:@std/assert";
import { add } from "./main.ts";

Deno.test(function addTest() {
  assertEquals(add(2, 3), 5);
});

Here's what's happening:

  1. We import the assertEquals function from Deno's standard library.
  2. We import our add function from the main file.
  3. We use Deno.test to define a test case.
  4. Inside the test, we use assertEquals to check if our function produces the expected result.

Running tests

To run your tests, use the deno test command in your terminal. Deno will automatically find and run all files with names ending in _test.ts or _test.js.

Asynchronous tests

Deno makes it easy to test asynchronous code too. Put an async keyword in front of your function parentheses in your test:

Deno.test("async function test", async () => {
  const result = await someAsyncFunction();
  assertEquals(result, expectedValue);
});

This way we can even test functions that work with Promises such as API calls for example.

More advanced testing

Deno's testing framework allows for more complex scenarios. You can use t.step() to create subtests within a larger test:

Deno.test("math operations", async (t) => {
  await t.step("addition", () => {
    assertEquals(add(2, 3), 5);
  });

  await t.step("multiplication", () => {
    assertEquals(multiply(2, 3), 6);
  });
});

This structure helps organize related tests and provides more granular test results. For example creating a user in a database before working with this test user.

Test Documentation Written in JSDoc

Deno also supports running tests and type checks inside JSDoc strings. The only thing we need is to put the code to run inside a block of code in the comment. Below is a sample doc string that shows how it's done.

/**
 * reverse is a function to reverse a string.
 *
 * # Examples
 * ```ts
 * import { assertEquals } from "jsr:@std/assert/equals";
 *
 * const reversed = reverse("hello");
 * assertEquals(reversed, "olleh");
 * ```
 */
export function reverse(s: string): string {
  return s.split("").reverse().join("");
}

If we now run deno test --doc (the --doc is important here), we can see in the output that Deno also executes the examples in our doc strings.

➜ deno test --doc
Check file:///Users/user/deno/main.ts$42-48.ts
Check file:///Users/user/deno/main.ts$57-63.ts
running 1 test from ./main.ts$42-48.ts
file:///Users/user/deno/main.ts$42-48.ts ... ok (0ms)
running 1 test from ./main.ts$57-63.ts
file:///Users/user/deno/main.ts$57-63.ts ... ok (0ms)

ok | 2 passed | 0 failed (12ms)

This is a cool feature when writing libraries, for example, to show people examples and also have tests written at the same time.

If you want to ignore the example from testing you can use the ignore attribute next to the declaration of the code block language.

/**
 * reverse is a function to reverse a string.
 *
 * # Examples
 * ```ts ignore
 * import { assertEquals } from "jsr:@std/assert/equals";
 *
 * const reversed = reverse("hello");
 * assertEquals(reversed, "olleh");
 * ```
 */
export function reverse(s: string): string {
  return s.split("").reverse().join("");
}

This feature was implemented in PR #25220 - so it’s pretty fresh right in time for Deno 2.0.

Test coverage

Deno provides built-in test coverage analysis. To use it, run your tests with the --coverage flag:

deno test --coverage

This will create a coverage profile. You can then use the deno coverage command to analyze this profile:

deno coverage

This will show you which parts of your code are covered by tests and which aren't. You can even generate a detailed HTML report of your coverage with the help of lcov and it's genhtml tool:

deno coverage --lcov --output=cov_profile
genhtml -o cov_profile/html cov_profile/lcov.info

This creates an interactive HTML page showing your code coverage, making it easy to identify areas that need more testing.

πŸ“‹ Further Documentation

bench

Performance is crucial in many applications, and Deno provides a built-in benchmarking tool to help you measure and optimize your code's performance.

What is benchmarking?

Benchmarking is the process of measuring the performance of a piece of code. It helps you understand how fast your code runs and can be crucial for identifying bottlenecks or comparing different implementations.

Basic usage

Let's start with a simple benchmark. Say we have a function we want to measure:

// main.ts
export const add = (a: number, b: number): number {
  return a + b;
}

We can create a benchmark for this function like this:

// bench.ts
import { add } from "./main.ts";

Deno.bench("addition benchmark", () => {
  add(2, 3);
});

To run this benchmark, use the deno bench command:

deno bench bench.ts

Deno will run your benchmark multiple times and provide statistics about its performance.

Understanding the output

When you run a benchmark, you'll see output in a table similar to this:

cpu: Apple M1
runtime: deno 1.40.3 (aarch64-apple-darwin)

file:///Users/user/project/bench.ts
benchmark      time (avg)        iter/s             (min … max)       p75       p99      p995
-------------------------------------------------- -----------------------------
addition benchmark    4.08 ns/iter   245,136,869.3    (3.99 ns … 9.9 ns)   4.09 ns   4.35 ns  4.4 ns

This output tells you:

  • The average time per iteration
  • How many iterations can be performed per second
  • The range of times (minimum to maximum)
  • The 75th, 99th, and 99.5th percentiles

When you have multiple files with multiple benchmarks Deno will go through all of them and run them one by one. This might take a moment but will result in output like this:

➜ deno bench -A
Check file:///Users/user/code/projects/deno-course/code/bench/read_bench.ts
Check file:///Users/user/code/projects/deno-course/code/bench/sort_bench.ts
cpu: Apple M1
runtime: deno 1.46.1 (aarch64-apple-darwin)

file:///Users/user/code/projects/deno-course/code/bench/read_bench.ts
benchmark                           time (avg)        iter/s             (min … max)       p75       p99      p995
------------------------------------------------------------------------------------ -----------------------------
readPokedex                          3.73 ms/iter         268.0     (3.61 ms … 4.19 ms) 3.76 ms 4.19 ms 4.19 ms
readPokedex with start and end       6.82 Β΅s/iter     146,692.1    (6.25 Β΅s … 26.46 Β΅s) 6.75 Β΅s 11 Β΅s 26.46 Β΅s


file:///Users/user/code/projects/deno-course/code/bench/sort_bench.ts
benchmark              time (avg)        iter/s             (min … max)       p75       p99      p995
----------------------------------------------------------------------- -----------------------------
Array.sort Arr1        92.29 ns/iter  10,835,411.7   (88.67 ns … 105.1 ns) 94.04 ns 102.99 ns 103.9 ns
Array.sort Arr2       100.98 ns/iter   9,902,601.0  (97.12 ns … 124.71 ns) 102.72 ns 107.85 ns 110.35 ns
MergeSorting Arr1     854.86 ns/iter   1,169,784.5 (839.41 ns … 921.54 ns) 858.71 ns 921.54 ns 921.54 ns
MergeSorting Arr2       1.07 Β΅s/iter     938,260.4     (1.05 Β΅s … 1.14 Β΅s) 1.07 Β΅s 1.14 Β΅s 1.14 Β΅s
quickSorting Arr1     569.39 ns/iter   1,756,266.6  (557.1 ns … 631.91 ns) 574.1 ns 631.91 ns 631.91 ns
quickSorting Arr2     724.42 ns/iter   1,380,410.7  (710.2 ns … 764.66 ns) 729.67 ns 764.66 ns 764.66 ns

More complex benchmarks

You can create more sophisticated benchmarks using additional options:

Deno.bench({
  name: "complex addition benchmark",
  fn: () => {
    add(Math.random() * 1000, Math.random() * 1000);
  },
  baseline: true,
  group: "math operations",
});

This benchmark:

  • Has a custom name
  • Uses a more complex function (with random numbers)
  • Is marked as a baseline (useful for comparing different implementations)
  • Belongs to a group (helpful for organizing related benchmarks even across files)

Benchmarking asynchronous code

Similar or even same as the asynchronous test functions we can even benchmark asynchronous functions. This is done in the same way putting the async in front of the parentheses. Regular Javascript syntax.

Deno.bench("async operation", async () => {
  await someAsyncOperation();
});

Controlling benchmark execution

Sometimes, you might want to control what part of your code is being timed. You can do this using b.start() and b.end():

Deno.bench("partial operation", (b) => {
  // Setup code (not timed)
  const data = prepareData();

  b.start();
  // This is the part that will be timed
  processData(data);
  b.end();

  // Cleanup code (not timed)
  cleanupData(data);
});

This allows you to exclude setup and cleanup time from your benchmark.

πŸ’‘ Note: Calling the benchmarking parameter b is your choice.

If you want to know more about the deno bench command. There is a lot of documentation about this in the deno bench article and in the Deno.bench API docs.

Best practices for benchmarking

  1. Run benchmarks multiple times to account for variability.
  2. Be aware of environmental factors (CPU load, etc.) that might affect results.
  3. Compare benchmarks on the same machine for consistency.
  4. Use realistic data and operations in your benchmarks.

By using Deno's benchmarking tools, you can gain valuable insights into your code's performance and make informed optimizations.

πŸ“‹ Further Documentation

compile

One of my favorite built in tools of Deno is a powerful tool that allows you to create standalone executables from your TypeScript or JavaScript code. This can be incredibly useful for distributing your applications to users who don't have Deno installed.

Basic usage

To compile a Deno application, use the deno compile command:

deno compile main.ts

This will create an executable file named after your source directory (e.g., foo or foo.exe on Windows). The binary is default for your CPU architecture of your host machine by default.

Cross-compilation

One of the most powerful features of Deno's compiler is its ability to cross-compile for different platforms. You can create executables for Windows, macOS, or Linux, regardless of your development platform.

To specify a target, use the --target flag:

deno compile --target x86_64-pc-windows-msvc main.ts

This would create a Windows executable, even if you're developing on macOS or Linux.

Available targets include:

  • x86_64-unknown-linux-gnu for x86 (Intel or AMD) Linux
  • aarch64-unknown-linux-gnu for ARM-based Linux
  • x86_64-pc-windows-msvc for x86 Windows
  • x86_64-apple-darwin for Intel-based macOS
  • aarch64-apple-darwin for ARM-based Macs

πŸ’‘ Note: You can find the architecture that Deno is running on when checking the Deno --version command.

Specifying permissions

When compiling, you need to specify all the permissions your application will need at runtime. For example:

deno compile --allow-read --allow-write main.ts

This creates an executable with permissions to read from and write to the file system.

Considerations

While Deno's compile feature is powerful, there are a few things to keep in mind:

  1. File size: Compiled executables can be large (50MB or more) because they include the Deno runtime.

  2. Performance: There might be a slight startup delay compared to running with deno run, as the executable needs to unpack the bundled code.

  3. Updates: The compiled executable is static. If you update your dependencies, you'll need to recompile.

  4. Platform specificity: While you can cross-compile, the resulting executable is specific to the target platform and architecture.

Best practices

  1. Minimize dependencies: The more external dependencies your project has, the larger the compiled executable will be.

  2. Test thoroughly: Make sure to test your compiled executable in an environment similar to where it will be deployed.

  3. Version your executables: Keep track of which source code version each executable was compiled from.

  4. Document required permissions: Make sure users know what system access your application requires.

Example: Creating a CLI tool

Let's say you've created a useful command-line tool with Deno. Here's how you might compile and distribute it:

// cli.ts
import { parse } from "https://deno.land/std/flags/mod.ts";

const { args } = Deno;
const parsedArgs = parse(args);

if (parsedArgs.help) {
  console.log("Usage: cli [options]");
  Deno.exit(0);
}

console.log("Hello from my CLI tool!");
// ... rest of your CLI logic

To compile this:

deno compile cli.ts

πŸ“‹ Further Documentation

Understanding import.meta

import.meta is a powerful feature in modern JavaScript that provides metadata about the current module. It's part of the ECMAScript specification and is supported in modern browsers and JavaScript runtimes, including Deno.

What is import.meta?

import.meta is an object that contains contextual information about the module in which it's accessed. The exact properties available on import.meta can vary depending on the environment, but Deno provides several useful properties.

Key Properties in Deno

  1. import.meta.url: This provides the URL of the current module. In Deno:

    • For local scripts (e.g., deno run main.ts), it's a file:// URL pointing to the script's location on the filesystem.
    • For remote scripts, it's the full URL of the script.
  2. import.meta.main: This boolean property is true if the current module is the main module that was directly run by Deno. It's used to write code that should execute when a file is run directly, not when it's imported by another module.

  3. import.meta.resolve(specifier): This method resolves a module specifier relative to the current module.

Practical Uses of import.meta

Let's look at practical examples of how you might use import.meta in your Deno projects:

  1. Determining if a module is the main module:

    if (import.meta.main) {
      console.log("This code only runs if this module is the main module");
    }
    

    This is even done in the main.ts file after running deno init.

  2. Getting the directory of the current module:

    import { dirname } from "jsr:@std/path";
    
    const currentDir = dirname(new URL(import.meta.url).pathname);
    console.log(`This module is located in: ${currentDir}`);
    

import.meta vs __dirname and __filename

If you're coming from Node.js, you might be used to __dirname and __filename. Deno doesn't have these globals, but you can achieve the same results with import.meta.url:

import { fromFileUrl } from "jsr:@std/path";

const __filename = fromFileUrl(import.meta.url);
const __dirname = new URL(".", import.meta.url).pathname;

This approach is more explicit about the source of these values and works consistently across different environments.

πŸ“‹ Further Documentation

Wrapping Up the Deno Toolchain

Concluding this whole chapter of the Deno toolbox you can see that we're getting a lot out of the box. The whole project is set up in a way that developers can start writing code right away without the need of setting up Typescript, linting, formatting or anything. It all just works.

In the next chapter we will talk about using Deno as a runtime to build a simple CLI tool. We'll learn about the permission model that makes applications more secure through disabling any permissions (e.g. network, filesystem) by default having to explicitly giving the script our allowance.

Deno on the CLI

Get ready to dive into the powerful world of Deno's security model and command line features! In this chapter, we'll explore how Deno's permissions system keeps your applications secure, learn how to use environment variables, and discover the art of parsing command line arguments.

We'll also uncover techniques for user interaction, gain insight into the host system, and even add a splash of color to your console output. In the end, you'll have the tools to create secure, interactive, and visually appealing Deno applications that stand out from the crowd. Let's unlock the full potential of the Deno runtime!

The Permission Model

A major drawback of NodeJS is that it will run any code that is launched by default with full permissions. This means that malicious code can run on our host without Node doing anything about it. Fortunately, this is not the case with Deno unless you explicitly give it the necessary permissions. For example, the --allow-net flag only allows network activities such as fetch requests.

There are two flags responsible for allowing access to the file system. --allow-read and --allow-write. Without these two flags, the application can try to read/write, but Deno will not allow it. This gives us an extra layer of security for our applications and host environments.

All available flags can be found in the documentation.

Specify flags

To avoid an "all or nothing" scenario and give our applications either full access or none at all, it is possible to specify the flags.

For example, we can customize the --allow-net flag to give access to example.com only. This can be done by passing domains to the --allow-net=example.com flag. If we try to make a request to mydomain.com, Deno will ask us if we allow it.

Let's take the following example:

// main.ts
const resExample = await fetch("https://example.com");
const resDeno = await fetch("https://deno.com");

console.log(resExample);
console.log(resDeno);

If we run the short script with deno run --allow-net=example.com main.ts, we will see Deno asking us for more permissions. This is because we only allowed access to example.com and not deno.com.

➜ deno run --allow-net=example.com main.ts
┏ ⚠️ Deno requests net access to "deno.com:443".
┠─ Requested by `fetch()` API.
┠─ Learn more at: https://docs.deno.com/go/--allow-net
┠─ Run again with --allow-net to bypass this prompt.
β”— Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all net permissions) >

We can restrict access to URLs or IPs, including ports. If we know in advance which domains will be used, we can provide our applications with a specific --allow-net flag for increased security.

See the documentation for more examples and information on restrictions for other flags.

Request access in apps

To prevent our applications from crashing unnecessarily while running, it is possible to check whether permissions are set or not. In the following example, we can check if it is possible for the application to access example.com.

const desc1 = { name: "net", host: "example.com" } as const;
console.log(await Deno.permissions.query(desc1));
await Deno.permissions.request(desc1);
console.log(await Deno.permissions.query(desc1));

As we will see below, the status is initially prompt, which means that the question has not yet been asked, so we need to use Deno.permissions.request to ask the user if it is okay to access example.com. If it is confirmed with Y, we will see in the next Deno.permissions.query call that the state: "granted" is set. Therefore we can now assume that we can communicate with example.com via network requests.

PermissionStatus { state: "prompt", onchange: null }
βœ… Granted net access to "example.com".
PermissionStatus { state: "granted", onchange: null }

The same is also available for read and write, so that we can, for example, determine where we are allowed to write data or have to take a different path. More information can be found in the Deno docs on the Permissions API or the Permissions Management Example on Deno by Examples.

Using Environment Variables

Sooner or later, our applications will need to access the host's environment variables. We can access them if the --allow-env flag was passed when the application was started. The operation is then done by simply calling the Deno.env API. For example, Deno.env.get("MY_VARIABLE").

A cool feature is Deno.env.toObject() - this gives us a complete object of all environment variables, where the key is the variable name and the value is the corresponding value of that variable. It would be possible to get the above variable like this

const { MY_VARIABLE } = Deno.env.toObject();

In many scenarios (e.g. in development) a lot of work is done with .env files. There is a module for this in the std library under @std/dotenv.

This works with an .env file as follows:

# .env
MY_VARIABLE=MY_VALUE

In the application, the .env files can then be accessed as follows.

import { load } from "jsr:@std/dotenv";

// 1st solution
const env = await load();
console.log(env["MY_VARIABLE"]);

// 2nd solution
await load({ export: true });
console.log(Deno.env.get("MY_VARIABLE"));

// 3rd solution
await load({ export: true });
const { MY_VARIABLE } = Deno.env.toObject();
console.log(MY_VARIABLE);

All three options have the same effect. The last two will export the variables to the underlying host at runtime. We need to make sure that Deno is also called with the --allow-read flag so that the .env file can be read.

Another nice security feature that Deno provides is the ability to restrict the variables that the application can access. For example, an application can only receive the PORT and HOST environment variables. In this case, all other host environment variables will be ignored or not passed to the application. To prevent the application from reading variables, it can be called with --allow-env=PORT,HOST. All other environment variables will then be inaccessible.

Parsing Command Line Arguments

No CLI tool is complete without variable input parameters to control the application. Accordingly, Deno offers the possibility to use and define arguments out of the box.

For this we have the parseArgs method from the std/cli library. This method uses the Deno.args API to read the passed parameters and define them in a meaningful object.

We have the option to define the possible arguments via a config. You can choose between Boolean, Collections or Strings. It offers the same features as minimist, which was used as an inspiration. In addition, we can define defaults, aliases and negatable variables.

The following is an extended example from Deno by Example, which includes a few more arguments.

import { parseArgs } from "jsr:@std/cli/parse-args";

const flags = parseArgs(Deno.args, {
  boolean: ["help", "color"],
  string: ["version"],
  default: { color: true },
  negatable: ["color"],
  collect: ["books"],
  alias: { help: "h" },
});

In the example we see that there are two boolean variables. One is help which shows the help output (--help) and the other is --color which can be used, for example, if you want to get colored output.

The advantage of this is that you do not have to enter --help true or --help=true, this is seen as the default if no value is given.

The --version flag can be filled with a variable string. For example, --version=1.0.0.

With the --books flag it is possible for us to record entries directly from the user, which can then be processed as an array. For example: --books alchemist --books 1984 --books "Atomic Habits" gives us an array of 3 strings [ "alchemist", 1984, "Atomic Habits" ] which we can process directly.

Negatable variables mean, for example, that instead of --color=false it is possible to write --no-color. The functionality remains the same. A --no- is added before the flag.

An alias is quickly explained. This gives us the option of calling up a flag under different names. In this case, we have the option of using --help or --h.

As an example we can look at the extended Deno by Example code:

import { parseArgs } from "jsr:@std/cli/parse-args";

const flags = parseArgs(Deno.args, {
  boolean: ["help", "color"],
  string: ["version"],
  default: { color: true },
  negatable: ["color"],
  collect: ["books"],
  alias: { help: "h" },
});

console.log("Wants help?", flags.help);
console.log("Version:", flags.version);
console.log("Wants color?:", flags.color);
console.log("Books:", flags.books);

console.log("Other:", flags._);

When we put this in a file called flags.ts for example, and run it with some random values for our flags, we get the output of:

➜ deno run flags.ts --help --books alchemist --books 1984 --books="Atomic Habits" --version=2.0.0 MyValue
Wants help? true
Version: 2.0.0
Wants color?: true
Books: [ "alchemist", 1984, "Atomic Habits" ]
Other: [ "MyValue" ]

What we can see here is that we have a value in our Other section. This comes from flags._ where everything that is not recognized as a flag is placed.

Prompt the user

From time to time user input is required within the application. Here we can use features that are available in almost all browsers (because Deno only uses browser technology, right?).

We can use prompt, alert and confirm to get input from the user.

Worth mentioning are alert and confirm, two nice helper functions that allow us to confirm (by pressing the Enter key) for alert, or to confirm the input of a y or N (confirm) without any effort. The latter returns a boolean value that we can use in our applications to find out whether the user has made a decision for or against the question.

// main.ts
alert("Be informed!");

const c = confirm("Are you sure?");
console.log("is the user sure?", c);
➜ deno run -A flags.ts
Be informed! [Enter]
Are you sure? [y/N] y
is the user sure? true

Host Interaction

We have in the Deno namespace some APIs that allow us to get information about the host system running our application.

For example, we can get the host name (Deno.hostname()), the uptime of the host (Deno.osUptime()), general information about the memory of the host or the running Deno process (Deno. systemMemoryInfo() or Deno.memoryUsage()), the process identifier (Deno.pid and Deno.ppid) or the path used by the Deno executable (Deno.execPath()).

An example of commands and their example outputs:

console.log("Hostname:", Deno.hostname());
console.log("uptime:", Deno.osUptime());
console.log("system Memory", Deno.systemMemoryInfo());
console.log("build:", Deno.build);
console.log("version:", Deno.version);

// Hostname: MacBook.local
// uptime: 52661
// system Memory [Object: null prototype] {
//   total: 17179869184,
//   free: 2177296,
//   available: 7781456,
//   buffers: 0,
//   cached: 0,
//   swapTotal: 0,
//   swapFree: 0
// }
// build: {
//   target: "aarch64-apple-darwin",
//   arch: "aarch64",
//   os: "darwin",
//   vendor: "apple",
//   env: undefined
// }
// version: { deno: "2.0.0-rc.10", v8: "12.9.202.13-rusty", typescript: "5.6.2" }

We also get information about the current working directory we are in (Deno.cwd()). This is because Deno allows us to interact with the file system and create, read or modify files. Additionally we can change file permissions or owners with the help of a Deno application as well.

A more detailed explanation of how Deno interacts with a host's file system can be found in a later section "Deno and the filesystem".

Colorful Message Logging

A good feature that is helpful for CLI applications is the ability to use simple CSS for console.log output. This can replace a library like chalk in some places.

For example, we can change the font color, decoration or size. This feature is available in many browsers, including Deno.

For this we need %c symbols that signal to the runtime where changes should take place, e.g. we can use console.log("Hello %cWorld%c", "color: green") to print the word "World" in green and thus indicate the success of a command in a CLI application, for example.

In addition to the simple color names, it is possible to specify these in hex or RGB values. Various changes can be defined by a string separated by a semicolon. E.g. console.log("Hello %cWorld", "color: green; text-decoration: underline; font-weight: bold").

πŸ“‹ Further Documentation

Wrapping up Deno on the CLI

In this chapter, we've explored the robust security features and powerful CLI capabilities that set Deno apart. We've covered:

  • Deno's permissions model, which provides granular control over application access
  • Secure use of environment variables
  • Parsing command line arguments for flexible user input
  • Interacting with users through prompts and alerts
  • Access host system information
  • Add visual flair with colorful console output

These features combine to make Deno a secure, versatile, and easy-to-use runtime for building modern CLI applications.

Deno and the file system

Many applications interact with the host file system in some way. For this we have several functions in Deno that are provided to us.

In order to use these in our applications, we need to remember to use the runtime flags, such as --allow-read and/or --allow-write.

In the following chapter we will look at how to interact with the filesystem, work with directories or manage files. At the end of the chapter we will create an application, similar to ls, which can give us information about directories.

Interact with Directories

Checking the contents of a folder

If we want our application to know what is in a folder, Deno offers us an easy way to find out. Deno.readDir() or synchronously Deno.readDirSync(). This method, like many of the following, is similar to NodeJS.

Creating new directories

As we are used to from the terminal/console, we can create new directories with Deno. The command is Deno.mkdir().

We have to keep in mind that the system does not check if the directory already exists when the command is being executed. If it does, Deno.mkdir("/my/path") will return an error. So we need to check that the directory exists first. We can do this with Deno.stat("/my/path"). Let's look at this in an example.

const path = "my-dir";

try {
  const stat = await Deno.stat(path);
  if (!stat.isDirectory) {
    throw new Error(`Path exists but is not a directory: ${path}`);
  }
  console.log(`Path exists and is a directory: ${path}`);
} catch (error) {
  if (error instanceof Deno.errors.NotFound) {
    console.log(`Path does not exist. We can create it: ${path}`);
    await Deno.mkdir(path);
  }
}

We can also create subfolders in one step. To do this, we pass a second parameter to the Deno.mkdir() function’s options with { recursive: true }.

const path = "my-dir/sub-dir";

try {
  const stat = await Deno.stat(path);
  if (!stat.isDirectory) {
    throw new Error(`Path exists but is not a directory: ${path}`);
  }
  console.log(`Path exists and is a directory: ${path}`);
} catch (error) {
  if (error instanceof Deno.errors.NotFound) {
    console.log(`Path does not exist. We can create it: ${path}`);
    await Deno.mkdir(path, { recursive: true });
  }
}

Walk directories

For example, if we want to build an application that displays the file system and its contents, we have the option of traversing the file system from a starting point. This method is provided by the @std/fs module from the standard library. Using an additional function from the @std/path library, we can create a kind of tree application.

import { walk } from "jsr:@std/fs/walk";
import { relative, SEPARATOR } from "jsr:@std/path";

const path = ".";

for await (const entry of walk(path, { includeDirs: true })) {
  const relativePath = relative(path, entry.path);
  const depth = relativePath.split(SEPARATOR).length - 1;
  const indent = "-->".repeat(depth);
  const prefix = entry.isDirectory ? "πŸ“ " : "πŸ“„ ";
  console.log(`${prefix}${indent} ${entry.name}`);
}

An example output would be the following, which shows my test project:

➜ deno run -A walk.ts
πŸ“  .
πŸ“  .zed
πŸ“„ --> settings.json
πŸ“„  main.ts
πŸ“„  deno.json
πŸ“„  walk.ts
πŸ“„  deno.lock
πŸ“„  .env
πŸ“„  flags.ts
πŸ“  my-dir
πŸ“ --> sub-dir

πŸ“‹ Further Documentation

Creating, reading and deleting files

Now we have looked at directories, interacted with them and created them. Of course, not only directories can be created, but files of all kinds. There are several methods in the Deno namespace and in the @std/fs library that we can use to do this.

The most important ones are described below.

Creating a new file

To create a new file, we can differentiate between a text file and a general file.

Creating text files

Deno offers us a simpler method for the former. We can use Deno.writeTextFile() to pass strings directly as input to the text file. We have an asynchronous promise-based method on the one hand, and a synchronous variant on the other. We have to wait for the former with an `await', but the following code can continue to run and will not block the application.

// async
await Deno.writeTextFile("hello.txt", "Hello, Deno!");
// sync
Deno.writeTextFileSync("hello.txt", "Hello, Deno!");

First we have the filename we want to assign. Then we can pass any string to be written to the text file. If the file already exists, it will be overwritten.

Of course, we don't always want to create a new file and overwrite existing data. To do this, we can use the method options (3rd parameter) to append our text to a file.

await Deno.writeTextFile("hello.txt", "Hello, Deno!", { append: true });

Creating arbitrary files

It is possible to use the Deno.create() method to create an empty file and optionally write bytes to it afterwards. Deno will then return a Deno.FsFile object that we can work with (read, write, truncate, etc.).

const f = await Deno.create("hello.txt");
const stats = await f.stat();
console.log(stats.isFile); // Result: true

To write bytes to this file, we need a Writer (and a TextEncoder if we want to write text). Let's have a look at what this looks like in code.

const file = await Deno.create("hello.txt");

// check the file size
const statsBefore = await file.stat();
console.log(statsBefore.size); // Result: 0

const writer = file.writable.getWriter();
await writer.write(new TextEncoder().encode("Hello Deno!"));

// check the size now
const statsAfter = await file.stat();
console.log(statsAfter.size); // Result: 11

// close the file so we don't leak
writer.close(); // or f.close() - writer.close() also closes the file

A simple workflow is provided by Deno.writeFile(). This allows us to significantly shorten the previous code.

await Deno.writeFile(
  "hello.txt",
  new TextEncoder().encode("Hello Deno!"),
);

const stats = await Deno.stat("hello.txt");
console.log(stats.size); // Result: 11

Reading the contents of a file

When we want to read a file, we can differentiate between a text file and a general file, just like when we create a file. For text files, there is a method that returns a string directly. If we want to read the file we created earlier, we can do this as follows.

const content = await Deno.readTextFile("hello.txt");
console.log(content); // Hello Deno!

However, if we want to load an arbitrary file into memory, we can use the Deno.readFile() method. This will return a byte array that we can work with.

const bytes = await Deno.readFile("hello.txt");
console.log(new TextDecoder().decode(bytes));

We don't have to worry about closing them with either method. Deno does that for us.

As with Deno.create(), we can open files to work with. For this we have the Deno.open() method. This will open the file and we can then work with it. Be it reading or writing. As with Deno.create() we get a Deno.FsFile object.

Removing files or directories

Deleting files and directories is simpler. Deno does not differentiate between the two types. With Deno.remove() or Deno.removeSync() we can remove both types.

// remove a file
await Deno.remove("hello.txt");

// remove an empty directory
await Deno.remove("my-dir");

// remove a non-empty directory
await Deno.remove("my-dir", { recursive: true });

When we try to delete a non-empty directory, we get an error message from Deno error: Uncaught (in promise) error: Directory not empty (os error 66): remove "my-dir". So we need to set the { recursive: true } option deliberately to remove everything in that directory.

Renaming and moving files

Sometimes we just want to move or rename files. For this we have the Deno.rename() function, which behaves similarly to the mv we may know from the terminal. The function is quickly explained. The first parameter is the current location and the second is the future location.

await Deno.rename("hello.txt", "./public/hello.txt");

It is important to note that the source location must be readable and the destination location must be writable for our application.

Temporary files

Temporary files are great for large amounts of data that you don't want to keep in memory. If they are to be used later in the program, we can use them to store data temporarily. Or, if you want to send them somewhere else, you can simply remove them at the end of the program. By default, these files are created in the operating system's temporary folder (such as /tmp or /var/folders). To get the current temporary directory, we can use one of the following two methods (preferably the first)

// method 1 via NodeJS compatibility layer
import { tmpdir } from "node:os";
const tempPath = tmpdir();
console.log(tempPath);

// method 2 using Deno environment variables
const tempDir = Deno.env.get("TMPDIR") ||
  Deno.env.get("TMP") ||
  Deno.env.get("TEMP");
console.log(tempDir);

For the creation of temporary files or directories we then have the functions Deno.makeTempDir() and Deno.makeTempFile().

const tempPath = await Deno.makeTempDir();
console.log(tempPath);

const tempFile = await Deno.makeTempFile();
console.log(tempFile);

Both functions return the path to the directory or file created, so we can interact with it.

The options that are optional as parameters help us to better identify the created files, as they have gibberish looking names. For example /var/folders/j_/g0c8_fyd0zq3vg76xr8xcyhc0000gn/T/fc8e227ff388a6c2. To make the files or directories more identifiable, we can use either {prefix: ""} or {suffix: ""} as an option (or both).

With the following code we get results like /var/folders/j_/g0c8_fyd0zq3vg76xr8xcyhc0000gn/T/temp_f0bba33b51e5bb67.txt.

const tempFile = await Deno.makeTempFile({ prefix: "temp_", suffix: ".txt" });
console.log(tempFile);

That way we know that it's probably a text file we're working with. This makes it easier to find and work with.

πŸ“‹ Further Documentation

Manage files (permissions, directories, statistics)

See statistics about files and directories

We can use Deno functions to get all sorts of information about our environment. For example, we can get statistics about files and directories. The Deno.stat() function is available for this purpose.

In the following we will look at a slimmed down implementation of the terminal application ls. We want to give the user information about the current directory. The output should return the available files, with optional CLI arguments for file size and last modified date.

In the following case, we create a file called ls.ts and insert the following code.

import { parseArgs } from "jsr:@std/cli/parse-args";
import { ensureDir } from "jsr:@std/fs";

interface FileInfo {
  name: string;
  size: number;
  mtime: Date | null;
  isDirectory: boolean;
  isFile: boolean;
  isSymlink: boolean;
}

interface Options {
  all: boolean;
  long: boolean;
  human: boolean;
}

/**
 * Convert a file size in bytes to a human-readable string.
 * @param sizeBytes The size in bytes.
 * @param human Whether to use human-readable units.
 * @returns The size as a string.
 */
function convertSize(sizeBytes: number, human = false): string {
  if (!human) return sizeBytes.toString();

  const units = ["B", "K", "M", "G", "T"];
  let size = sizeBytes;
  let unitIndex = 0;

  while (size >= 1024 && unitIndex < units.length - 1) {
    size /= 1024;
    unitIndex++;
  }

  return `${size.toFixed(1)}${units[unitIndex]}`;
}

/**
 * Get file information for a directory entry.
 * @param path The path to the directory.
 * @param entry The directory entry.
 * @returns The file information.
 */
async function getFileInfo(
  path: string,
  entry: Deno.DirEntry,
): Promise<FileInfo> {
  const fullPath = `${path}/${entry.name}`;
  const stat = await Deno.stat(fullPath);

  return {
    name: entry.name,
    size: stat.size,
    mtime: stat.mtime,
    isDirectory: entry.isDirectory,
    isFile: entry.isFile,
    isSymlink: entry.isSymlink,
  };
}

/**
 * List the contents of a directory.
 * @param path The path to the directory.
 * @param options The options to use.
 */
async function listDirectory(path: string, options: Options) {
  const entries: Deno.DirEntry[] = [];

  for await (const entry of Deno.readDir(path)) {
    if (!options.all && entry.name.startsWith(".")) continue;
    entries.push(entry);
  }

  // Sort entries
  entries.sort((a, b) => a.name.localeCompare(b.name));

  if (options.long) {
    // Add . and .. if showing all
    if (options.all) {
      const currentDir = await Deno.stat(".");
      const parentDir = await Deno.stat("..");

      console.log(formatLongEntry({
        name: ".",
        size: currentDir.size,
        mtime: currentDir.mtime,
        isDirectory: true,
        isFile: false,
        isSymlink: false,
      }, options));

      console.log(formatLongEntry({
        name: "..",
        size: parentDir.size,
        mtime: parentDir.mtime,
        isDirectory: true,
        isFile: false,
        isSymlink: false,
      }, options));
    }

    // Process and display each entry
    for (const entry of entries) {
      const info = await getFileInfo(path, entry);
      console.log(formatLongEntry(info, options));
    }

    return;
  }
  // Simple listing
  for (const entry of entries) {
    console.log(entry.name);
  }

  return;
}

/**
 * Format a file information object for a long listing.
 * @param info The file information.
 * @param options The options to use.
 * @returns The formatted string.
 */
function formatLongEntry(info: FileInfo, options: Options): string {
  const mtime = info.mtime
    ? info.mtime.toLocaleDateString("en-US", {
      month: "short",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
    })
    : "???";

  const size = convertSize(info.size, options.human);

  return `${size.padStart(6)} ${mtime} ${info.name}`;
}

async function main() {
  const args = parseArgs(Deno.args, {
    boolean: ["a", "l", "h"],
    alias: {
      a: "all",
      l: "long",
      h: "human",
    },
  });

  const options: Options = {
    all: args.a || false,
    long: args.l || false,
    human: args.h || false,
  };

  const path = args._[0]?.toString() || ".";

  try {
    await ensureDir(path);
    await listDirectory(path, options);
  } catch (error) {
    console.error(`Error: ${error.message}`);
    Deno.exit(1);
  }
}

if (import.meta.main) {
  main();
}

We can run this application with deno run --allow-read ls.ts <dir> <parameter>. If we run it without specifying any directories or parameters, we will get the information from the current directory.

➜ deno run --allow-read ls.ts
deno.json
deno.lock
ls.ts

It gets interesting when we use one (or more) of our parameters. We have the parameters -a, -l and -h. The last one works in combination with -l. When used, we get the following output from our application.

➜ deno run --allow-read ls.ts -alh
160.0B Nov 17, 03:35 PM .
 96.0B Oct 16, 05:37 PM ..
118.0B Nov 17, 03:36 PM deno.json
675.0B Nov 17, 03:36 PM deno.lock
  3.7K Nov 17, 03:37 PM ls.ts

As a result, we have built our own ls application in a simple way.

πŸ“‹ Further Documentation

Concluding "Deno and the file system"

In this chapter we learned about Deno's file system operations. We covered how to interact with directories, including checking their contents, creating new ones, and traversing through them using the walk function. We then explored file operations such as creating, reading, and deleting files, as well as working with temporary files. The chapter demonstrated both synchronous and asynchronous methods, showing how to handle file permissions and statistics.

Throughout the examples, we saw how Deno provides a secure approach to file system operations by requiring explicit permissions through runtime flags like --allow-read and --allow-write. We concluded with a practical implementation of an ls-like command-line tool, which brought together many of the concepts we learned.