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 aFuture#[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_corecrate. - Use the
#[op2]macro to define ops with automatic V8 type marshaling for sync, async, or fast calls. - Register ops in an
ExtensionusingExtension::builder()and inject it intoJsRuntimebefore script execution. - Access ops from JavaScript via
Deno.core.opsusing the snake_case function name. - Manage persistent state with
OpStateand 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →