How to Write Custom Operations (Ops) in Deno: A Complete Guide

You write custom operations in Deno by defining a Rust function annotated with #[op2], registering it in an Extension, and injecting that extension into a JsRuntime, making the function callable from JavaScript via Deno.core.ops.

Deno's JavaScript runtime is built on the deno_core crate, which bridges Rust and V8 through a mechanism called operations (ops). If you need to expose native functionality to JavaScript in the denoland/deno repository or in a custom embedding, you must write custom operations that follow the specific patterns established in the core architecture.

Understanding Deno Operations Architecture

Deno exposes native functionality to JavaScript through ops—Rust functions that receive an &mut OpState and return a Result<T, E>. The #[op2] macro generates the boilerplate that marshals V8 arguments, performs type conversion, and registers the function with the extension system.

Component Role Source
OpState Holds per‑runtime state (resource table, permissions, etc.). Ops can read/write data via this structure. [runtime/worker.rs](https://github.com/denoland/deno/blob/main/runtime/worker.rs)
#[op2] macro Generates boilerplate that marshals V8 arguments, performs type conversion (FromV8/ToV8), and registers the function name with the extension. defined in the deno_core crate
Extension A collection of ops and optional JavaScript/ESM sources. Extensions are passed to JsRuntime (or a worker) during creation. [runtime/shared.rs](https://github.com/denoland/deno/blob/main/runtime/shared.rs)
JsRuntime / Workers The actual V8 isolate. When constructed it receives a list of Extensions, merges the op tables, and makes the ops callable from JavaScript. [runtime/worker.rs](https://github.com/denoland/deno/blob/main/runtime/worker.rs)
Existing Ops Real‑world examples (file‑system, networking, timers, …) that you can copy‑paste and adapt. e.g. [ext/fs/ops.rs](https://github.com/denoland/deno/blob/main/ext/fs/ops.rs)

Step-by-Step Guide to Writing Custom Ops in Deno

Define the Rust Function with #[op2]

Create a Rust function and annotate it with #[op2]. Choose the appropriate variant for your use case:

  • #[op2] for synchronous operations
  • #[op2(async)] for asynchronous operations returning a Future
  • #[op2(fast)] for optimized synchronous calls

Use argument attribute shortcuts to map V8 types: #[string] for JavaScript strings, #[smi] for small integers, #[buffer] for ArrayBuffers, and #[number] for floating-point values. Always return Result<T, AnyError> where T implements ToV8.

Build an Extension to Register the Op

Wrap your op in an Extension using the builder pattern. In runtime/shared.rs, you can see how Deno constructs its built-in extensions. For custom ops, create a function that returns Extension:

use deno_core::{Extension, OpState, op2, AnyError};

#[op2]
#[string]
pub fn op_echo(state: &mut OpState, msg: &str) -> Result<String, AnyError> {
    Ok(msg.to_owned())
}

pub fn echo_extension() -> Extension {
    Extension::builder()
        .ops(vec![op_echo::decl()])
        .build()
}

Inject the Extension into JsRuntime

In runtime/worker.rs, the JsRuntime is instantiated with a vector of extensions. To make your op available, push your extension into this vector before creating the runtime:

use deno_core::JsRuntime;

fn main() {
    let mut runtime = JsRuntime::new(Default::default());
    runtime.extensions.push(echo_extension());
    
    runtime
        .execute_script("<anon>", r#"Deno.core.ops.op_echo("Hello from JS!")"#)
        .unwrap();
}

Call the Op from JavaScript

All registered ops are automatically exposed under Deno.core.ops. The JavaScript name is derived from the Rust function name (snake_case is preserved as-is in the ops object). For the op_echo example above:

const result = Deno.core.ops.op_echo("🔧 custom op");
console.log(result); // → "🔧 custom op"

Handle Async Operations and Resources

For asynchronous operations, use #[op2(async)] and return a Future. The macro automatically converts the result to a JavaScript Promise:

use deno_core::{op2, AnyError};
use std::time::Duration;
use tokio::time::sleep;

#[op2(async)]
#[number]
pub async fn op_sleep(ms: u64) -> Result<(), AnyError> {
    sleep(Duration::from_millis(ms)).await;
    Ok(())
}

To manage persistent state like file handles or network sockets, implement the Resource trait and store instances in OpState's resource table. As shown in ext/fs/ops.rs, you can add resources with state.resource_table.add() and retrieve them with state.resource_table.get::<T>(rid).

Complete Code Examples for Custom Deno Ops

Synchronous String Reversal Op

This example demonstrates a minimal synchronous op that receives a string and returns a transformed value:

use deno_core::{Extension, OpState, op2, AnyError};

#[op2]
#[string]
pub fn op_reverse(state: &mut OpState, input: &str) -> Result<String, AnyError> {
    Ok(input.chars().rev().collect())
}

pub fn reverse_extension() -> Extension {
    Extension::builder()
        .ops(vec![op_reverse::decl()])
        .build()
}

Asynchronous Sleep Op

This pattern shows how to handle async I/O or timers without blocking the V8 thread:

use deno_core::{Extension, op2, AnyError};
use std::time::Duration;
use tokio::time::sleep;

#[op2(async)]
#[number]
pub async fn op_sleep(ms: u64) -> Result<(), AnyError> {
    sleep(Duration::from_millis(ms)).await;
    Ok(())
}

pub fn sleep_extension() -> Extension {
    Extension::builder()
        .ops(vec![op_sleep::decl()])
        .build()
}

Custom Resource Counter Op

This advanced example implements a resource that persists across multiple op calls, similar to how file descriptors work in ext/fs/ops.rs:

use deno_core::{Extension, OpState, op2, Resource, ResourceId, AnyError};
use std::cell::RefCell;
use std::rc::Rc;

struct Counter(i64);

impl Resource for Counter {}

#[op2]
pub fn op_counter_new(state: &mut OpState) -> Result<ResourceId, AnyError> {
    let rid = state.resource_table.add(Counter(0));
    Ok(rid)
}

#[op2]
pub fn op_counter_inc(state: &mut OpState, #[smi] rid: ResourceId) -> Result<i64, AnyError> {
    let mut counter = state.resource_table.get_mut::<Counter>(rid)?;
    counter.0 += 1;
    Ok(counter.0)
}

pub fn counter_extension() -> Extension {
    Extension::builder()
        .ops(vec![
            op_counter_new::decl(),
            op_counter_inc::decl(),
        ])
        .build()
}

JavaScript usage:

const { op_counter_new, op_counter_inc } = Deno.core.ops;
const rid = op_counter_new();
console.log(op_counter_inc(rid)); // 1
console.log(op_counter_inc(rid)); // 2

Common Pitfalls When Writing Deno Ops

Issue Why it happens Fix
Op not found at runtime The extension wasn’t added to the runtime’s extensions vector. Ensure runtime.extensions.push(your_extension()) is called before executing scripts.
Type mismatch errors Arguments aren’t annotated correctly (#[string], #[smi], etc.) or the return type can’t be converted. Use the macros documented in deno_core (see the source of existing ops for patterns).
Permission errors Ops that touch the filesystem or network must call the permissions container (as built‑ins do). Use state.borrow_mut::<deno_permissions::PermissionsContainer>() to check permissions before performing privileged actions.
Resource leaks Forgetting to close resources added to the resource table. Implement Resource for your custom type and let the runtime drop it, or manually call resource_table.close(rid).

Key Source Files for Deno Op Development

These files in the denoland/deno repository illustrate the full lifecycle of custom operations:

File Why it matters
[runtime/worker.rs](https://github.com/denoland/deno/blob/main/runtime/worker.rs) Defines JsRuntime, the extensions field, and the plumbing that merges op tables from all extensions.
[runtime/shared.rs](https://github.com/denoland/deno/blob/main/runtime/shared.rs) Shows how the core extensions (flags, runtime_main, etc.) are built with Extension::builder().
[runtime/snapshot.rs](https://github.com/denoland/deno/blob/main/runtime/snapshot.rs) Demonstrates creating a snapshot that includes custom extensions – useful for embedding Deno.
[ext/fs/ops.rs](https://github.com/denoland/deno/blob/main/ext/fs/ops.rs) Real‑world op implementations (both sync & async) with permission checks, resource handling, and the #[op2] macro usage.

Summary

  • Custom operations bridge Rust and JavaScript in Deno through the deno_core crate.
  • Use the #[op2] macro to define ops with automatic V8 type marshaling for sync, async, or fast calls.
  • Register ops in an Extension using Extension::builder() and inject it into JsRuntime before script execution.
  • Access ops from JavaScript via Deno.core.ops using the snake_case function name.
  • Manage persistent state with OpState and the resource table for handles that survive multiple calls.

Frequently Asked Questions

What is the difference between #[op2] and the older op macro?

The #[op2] macro is the modern replacement for the original #[op] macro in deno_core. It provides improved type safety, better performance optimizations through the #[op2(fast)] variant, and clearer attribute syntax for argument conversion such as #[string] and #[smi]. All new custom operations in the denoland/deno repository use #[op2].

How do I pass complex objects between JavaScript and Rust in Deno ops?

For complex objects, use the serde serialization traits. Define a Rust struct deriving Deserialize for incoming data and Serialize for outgoing data, then accept or return the struct in your op function. The #[op2] macro automatically handles the conversion between V8 objects and Rust structs when the type implements the appropriate serde traits.

Can I write custom ops without modifying the Deno source code?

Yes, you can write custom ops for custom embeddings of deno_core without modifying the main denoland/deno repository. Create a separate Rust project that depends on deno_core, define your ops and extensions, and construct a JsRuntime with your extensions. This approach is common for building specialized JavaScript runtimes that extend Deno's capabilities.

How do I handle permissions in custom Deno operations?

Custom ops that perform privileged actions should check permissions using the PermissionsContainer stored in OpState. Access the permissions via state.borrow_mut::<deno_permissions::PermissionsContainer>() and call methods like check_read() or check_write() before performing filesystem or network operations. This mirrors the security model used in built-in ops found in ext/fs/ops.rs.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →