Repository: denoland/deno_core
Pull request #726 (2024-05-02 20:20:35)

Support ToV8/FromV8 in `#[op2]`
Adds support for

```rust
// calls `Foo`'s `ToV8` impl
#[op2]
#[to_v8]
fn op_foo() -> Foo {
  todo!()
}

// calls `Bar`'s `FromV8` impl
#[op2]
fn op_bar(#[from_v8] bar: Bar) { }
```


Patch:
diff --git a/core/runtime/ops_rust_to_v8.rs b/core/runtime/ops_rust_to_v8.rs
index d0e3fd7..d8bec71 100644
--- a/core/runtime/ops_rust_to_v8.rs
+++ b/core/runtime/ops_rust_to_v8.rs
@@ -161,6 +161,9 @@ impl Marker for NumberMarker {}
 pub struct ArrayBufferMarker;
 impl Marker for ArrayBufferMarker {}
 
+pub struct ToV8Marker;
+impl Marker for ToV8Marker {}
+
 trait Marker {}
 
 /// Helper macro for [`RustToV8`] to reduce boilerplate.
@@ -402,6 +405,21 @@ impl<'a, T: serde::Serialize> RustToV8Fallible<'a>
   }
 }
 
+impl<'a, T: crate::ToV8<'a>> RustToV8Fallible<'a>
+  for RustToV8Marker<ToV8Marker, T>
+{
+  #[inline(always)]
+  fn to_v8_fallible(
+    self,
+    scope: &mut v8::HandleScope<'a>,
+  ) -> serde_v8::Result<v8::Local<'a, v8::Value>> {
+    self
+      .0
+      .to_v8(scope)
+      .map_err(|e| serde_v8::Error::Custom(Box::new(e)))
+  }
+}
+
 //
 // SMI
 //
diff --git a/ops/op2/dispatch_fast.rs b/ops/op2/dispatch_fast.rs
index ae6cbc2..9567cc7 100644
--- a/ops/op2/dispatch_fast.rs
+++ b/ops/op2/dispatch_fast.rs
@@ -810,6 +810,7 @@ fn map_arg_to_v8_fastcall_type(
     | Arg::OptionString(_)
     | Arg::OptionBuffer(..)
     | Arg::SerdeV8(_)
+    | Arg::FromV8(_)
     | Arg::Ref(..) => return Ok(None),
     // We don't support v8 global arguments
     Arg::V8Global(_) | Arg::OptionV8Global(_) => return Ok(None),
@@ -859,7 +860,7 @@ fn map_retval_to_v8_fastcall_type(
   arg: &Arg,
 ) -> Result<Option<V8FastCallType>, V8MappingError> {
   let rv = match arg {
-    Arg::OptionNumeric(..) | Arg::SerdeV8(_) => return Ok(None),
+    Arg::OptionNumeric(..) | Arg::SerdeV8(_) | Arg::ToV8(_) => return Ok(None),
     Arg::Void => V8FastCallType::Void,
     Arg::Numeric(NumericArg::bool, _) => V8FastCallType::Bool,
     Arg::Numeric(NumericArg::u32, _)
diff --git a/ops/op2/dispatch_slow.rs b/ops/op2/dispatch_slow.rs
index 0c06752..2c8ff66 100644
--- a/ops/op2/dispatch_slow.rs
+++ b/ops/op2/dispatch_slow.rs
@@ -520,6 +520,22 @@ pub fn from_arg(
         };
       }
     }
+    Arg::FromV8(ty) => {
+      *needs_scope = true;
+      let ty =
+        syn::parse_str::<syn::Type>(ty).expect("Failed to reparse state type");
+      let scope = scope.clone();
+      let err = format_ident!("{}_err", arg_ident);
+      let throw_exception = throw_type_error_string(generator_state, &err)?;
+      quote! {
+        let #arg_ident = match <#ty as deno_core::FromV8>::from_v8(&mut #scope, #arg_ident) {
+          Ok(t) => t,
+          Err(#err) => {
+            #throw_exception;
+          }
+        };
+      }
+    }
     Arg::CppGcResource(ty) => {
       let throw_exception =
         throw_type_error(generator_state, format!("expected {}", &ty))?;
@@ -741,6 +757,9 @@ pub fn return_value_infallible(
       generator_state.needs_scope = true;
       gs_quote!(generator_state(scope, result) => (deno_core::v8::Local::<deno_core::v8::Value>::from(deno_core::cppgc::make_cppgc_object(&mut #scope, #result))))
     }
+    ArgMarker::ToV8 => {
+      gs_quote!(generator_state(result) => (deno_core::_ops::RustToV8Marker::<deno_core::_ops::ToV8Marker, _>::from(#result)))
+    }
     ArgMarker::None => gs_quote!(generator_state(result) => (#result)),
   };
   let res = match ret_type.slow_retval() {
@@ -806,6 +825,9 @@ pub fn return_value_v8_value(
     ArgMarker::Cppgc => {
       quote!(deno_core::cppgc::make_cppgc_object(#scope, #result))
     }
+    ArgMarker::ToV8 => {
+      quote!(deno_core::_ops::RustToV8Marker::<deno_core::_ops::ToV8Marker, _>::from(#result))
+    }
     ArgMarker::None => quote!(#result),
   };
   let res = match ret_type.slow_retval() {
diff --git a/ops/op2/signature.rs b/ops/op2/signature.rs
index 167b2e8..0af1af1 100644
--- a/ops/op2/signature.rs
+++ b/ops/op2/signature.rs
@@ -281,6 +281,8 @@ pub enum Arg {
   CppGcResource(String),
   WasmMemory(RefType, WasmMemorySource),
   OptionWasmMemory(RefType, WasmMemorySource),
+  FromV8(String),
+  ToV8(String),
 }
 
 impl Arg {
@@ -459,6 +461,7 @@ impl Arg {
         // Fast return value path for empty strings
         Arg::String(_) => ArgSlowRetval::RetValFallible,
         Arg::SerdeV8(_) => ArgSlowRetval::V8LocalFalliable,
+        Arg::ToV8(_) => ArgSlowRetval::V8LocalFalliable,
         // No scope required for these
         Arg::V8Local(_) => ArgSlowRetval::V8LocalNoScope,
         Arg::V8Global(_) => ArgSlowRetval::V8Local,
@@ -493,6 +496,7 @@ impl Arg {
       Arg::Numeric(NumericArg::__SMI__, _) => ArgMarker::Smi,
       Arg::Numeric(_, NumericFlag::Number) => ArgMarker::Number,
       Arg::CppGcResource(_) => ArgMarker::Cppgc,
+      Arg::ToV8(_) => ArgMarker::ToV8,
       _ => ArgMarker::None,
     }
   }
@@ -531,6 +535,8 @@ pub enum ArgMarker {
   ArrayBuffer,
   /// This type should be wrapped as a cppgc V8 object.
   Cppgc,
+  /// This type should be converted with `ToV8``
+  ToV8,
 }
 
 #[derive(Debug)]
@@ -757,6 +763,10 @@ pub enum WasmMemorySource {
 pub enum AttributeModifier {
   /// #[serde], for serde_v8 types.
   Serde,
+  /// #[to_v8], for types that impl `ToV8`
+  ToV8,
+  /// #[from_v8] for types that impl `FromV8`
+  FromV8,
   /// #[smi], for non-integral ID types representing small integers (-2³¹ and 2³¹-1 on 64-bit platforms,
   /// see https://medium.com/fhinkel/v8-internals-how-small-is-a-small-integer-e0badc18b6da).
   Smi,
@@ -781,6 +791,8 @@ pub enum AttributeModifier {
 impl AttributeModifier {
   fn name(&self) -> &'static str {
     match self {
+      AttributeModifier::ToV8 => "to_v8",
+      AttributeModifier::FromV8 => "from_v8",
       AttributeModifier::Bigint => "bigint",
       AttributeModifier::Number => "number",
       AttributeModifier::Buffer(..) => "buffer",
@@ -855,8 +867,6 @@ pub enum ArgError {
   InvalidSerdeType(String, &'static str),
   #[error("Invalid #[{0}] for type: {1}")]
   InvalidAttributeType(&'static str, String),
-  #[error("Cannot use #[serde] for type: {0}")]
-  InvalidSerdeAttributeType(String),
   #[error("Cannot use #[number] for type: {0}")]
   InvalidNumberAttributeType(String),
   #[error("Invalid v8 type: {0}")]
@@ -877,6 +887,8 @@ pub enum ArgError {
   ExpectedCppGcReference(String),
   #[error("Invalid #[cppgc] type '{0}'")]
   InvalidCppGcType(String),
+  #[error("#[{0}] is only valid in {1} position")]
+  InvalidAttributePosition(&'static str, &'static str),
 }
 
 #[derive(Error, Debug)]
@@ -1258,6 +1270,8 @@ fn parse_attribute(
       (#[global]) => Some(AttributeModifier::Global),
       (#[memory(caller)]) => Some(AttributeModifier::WasmMemory(WasmMemorySource::Caller)),
       (#[cppgc]) => Some(AttributeModifier::CppGcResource),
+      (#[to_v8]) => Some(AttributeModifier::ToV8),
+      (#[from_v8]) => Some(AttributeModifier::FromV8),
       (#[allow ($_rule:path)]) => None,
       (#[doc = $_attr:literal]) => None,
       (#[cfg $_cfg:tt]) => None,
@@ -1469,6 +1483,24 @@ fn parse_cppgc(position: Position, ty: &Type) -> Result<Arg, ArgError> {
   }
 }
 
+fn better_alternative_exists(position: Position, of: &TypePath) -> bool {
+  // If this type will parse without #[serde]/#[to_v8]/#[from_v8], it is illegal to use this type
+  // with #[serde]/#[to_v8]/#[from_v8]
+  if parse_type_path(position, Attributes::default(), TypePathContext::None, of)
+    .is_ok()
+  {
+    return true;
+  }
+  // If this type will parse with #[string], it is illegal to use this type with #[serde]/#[to_v8]/#[from_v8]
+  if parse_type_path(position, Attributes::string(), TypePathContext::None, of)
+    .is_ok()
+  {
+    return true;
+  }
+
+  false
+}
+
 pub(crate) fn parse_type(
   position: Position,
   attrs: Attributes,
@@ -1480,56 +1512,68 @@ pub(crate) fn parse_type(
   if let Some(primary) = attrs.primary {
     match primary {
       AttributeModifier::CppGcResource => return parse_cppgc(position, ty),
-      AttributeModifier::Serde => match ty {
-        Type::Tuple(of) => {
-          return Ok(Arg::SerdeV8(stringify_token(of)));
-        }
-        Type::Path(of) => {
-          // If this type will parse without #[serde] (or with #[string]), it is illegal to use this type with #[serde]
-          if parse_type_path(
-            position,
-            Attributes::default(),
-            TypePathContext::None,
-            of,
-          )
-          .is_ok()
-          {
-            return Err(ArgError::InvalidSerdeAttributeType(stringify_token(
-              ty,
-            )));
-          }
-          // If this type will parse without #[serde] (or with #[string]), it is illegal to use this type with #[serde]
-          if parse_type_path(
-            position,
-            Attributes::string(),
-            TypePathContext::None,
-            of,
-          )
-          .is_ok()
-          {
-            return Err(ArgError::InvalidSerdeAttributeType(stringify_token(
-              ty,
-            )));
-          }
+      AttributeModifier::FromV8 if position == Position::RetVal => {
+        return Err(ArgError::InvalidAttributePosition(
+          primary.name(),
+          "argument",
+        ))
+      }
+      AttributeModifier::ToV8 if position == Position::Arg => {
+        return Err(ArgError::InvalidAttributePosition(
+          primary.name(),
+          "return value",
+        ))
+      }
+      AttributeModifier::Serde
+      | AttributeModifier::FromV8
+      | AttributeModifier::ToV8 => {
+        let make_arg = match primary {
+          AttributeModifier::Serde => Arg::SerdeV8,
+          AttributeModifier::FromV8 => Arg::FromV8,
+          AttributeModifier::ToV8 => Arg::ToV8,
+          _ => unreachable!(),
+        };
+        match ty {
+          Type::Tuple(of) => return Ok(make_arg(stringify_token(of))),
+          Type::Path(of) => {
+            if better_alternative_exists(position, of) {
+              return Err(ArgError::InvalidAttributeType(
+                primary.name(),
+                stringify_token(ty),
+              ));
+            }
 
-          // Denylist of serde_v8 types with better alternatives
-          let ty = of.into_token_stream();
-          let token = stringify_token(of.path.clone());
-          if let Ok(Some(err)) = std::panic::catch_unwind(|| {
-            rules!(ty => {
-              ( Value $( < $_lifetime:lifetime $(,)? >)? ) => Some("use a fully-qualified type: v8::Value or serde_json::Value"),
-              ( $_ty:ty ) => None,
-            })
-          }) {
-            return Err(ArgError::InvalidSerdeType(stringify_token(ty), err));
-          }
+            let ty = of.into_token_stream();
+            if let Ok(Some(err)) = std::panic::catch_unwind(|| {
+              rules!(ty => {
+                ( Value $( < $_lifetime:lifetime $(,)? >)? ) => Some("a fully-qualified type: v8::Value or serde_json::Value"),
+                ( $_ty:ty ) => None,
+              })
+            }) {
+              if primary == AttributeModifier::Serde {
+                return Err(ArgError::InvalidSerdeType(
+                  stringify_token(ty),
+                  err,
+                ));
+              } else {
+                return Err(ArgError::InvalidAttributeType(
+                  primary.name(),
+                  stringify_token(ty),
+                ));
+              }
+            }
 
-          return Ok(Arg::SerdeV8(token));
-        }
-        _ => {
-          return Err(ArgError::InvalidSerdeAttributeType(stringify_token(ty)))
+            return Ok(make_arg(stringify_token(of.path.clone())));
+          }
+          _ => {
+            return Err(ArgError::InvalidAttributeType(
+              primary.name(),
+              stringify_token(ty),
+            ));
+          }
         }
-      },
+      }
+
       AttributeModifier::State => {
         return parse_type_state(ty);
       }
@@ -2073,12 +2117,31 @@ mod tests {
 
   expect_fail!(
     op_with_bad_serde_string,
-    ArgError("s", InvalidSerdeAttributeType("String")),
+    ArgError("s", InvalidAttributeType("serde", "String")),
     fn f(#[serde] s: String) {}
   );
   expect_fail!(
     op_with_bad_serde_str,
-    ArgError("s", InvalidSerdeAttributeType("&str")),
+    ArgError("s", InvalidAttributeType("serde", "&str")),
     fn f(#[serde] s: &str) {}
   );
+
+  expect_fail!(
+    op_with_bad_from_v8_string,
+    ArgError("s", InvalidAttributeType("from_v8", "String")),
+    fn f(#[from_v8] s: String) {}
+  );
+
+  expect_fail!(
+    op_with_to_v8_arg,
+    ArgError("s", InvalidAttributePosition("to_v8", "return value")),
+    fn f(#[to_v8] s: Foo) {}
+  );
+
+  expect_fail!(
+    op_with_from_v8_ret,
+    RetError(InvalidType(InvalidAttributePosition("from_v8", "argument"))),
+    #[from_v8]
+    fn f() -> Foo {}
+  );
 }
diff --git a/serde_v8/error.rs b/serde_v8/error.rs
index 2749692..ec1f38e 100644
--- a/serde_v8/error.rs
+++ b/serde_v8/error.rs
@@ -3,7 +3,7 @@ use std::fmt::Display;
 
 pub type Result<T> = std::result::Result<T, Error>;
 
-#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
+#[derive(Debug, thiserror::Error)]
 #[non_exhaustive]
 pub enum Error {
   #[error("{0}")]
@@ -58,6 +58,9 @@ pub enum Error {
 
   #[error("serde_v8 error: can't create slice from resizable ArrayBuffer")]
   ResizableBackingStoreNotSupported,
+
+  #[error("{0}")]
+  Custom(Box<dyn std::error::Error + Send + Sync + 'static>),
 }
 
 impl serde::ser::Error for Error {


Test patch:
diff --git a/ops/op2/test_cases/sync/from_v8.out b/ops/op2/test_cases/sync/from_v8.out
new file mode 100644
index 0000000..07725a3
--- /dev/null
+++ b/ops/op2/test_cases/sync/from_v8.out
@@ -0,0 +1,100 @@
+#[allow(non_camel_case_types)]
+pub const fn op_from_v8_arg() -> ::deno_core::_ops::OpDecl {
+    #[allow(non_camel_case_types)]
+    pub struct op_from_v8_arg {
+        _unconstructable: ::std::marker::PhantomData<()>,
+    }
+    impl ::deno_core::_ops::Op for op_from_v8_arg {
+        const NAME: &'static str = stringify!(op_from_v8_arg);
+        const DECL: ::deno_core::_ops::OpDecl = ::deno_core::_ops::OpDecl::new_internal_op2(
+            ::deno_core::__op_name_fast!(op_from_v8_arg),
+            false,
+            false,
+            1usize as u8,
+            Self::v8_fn_ptr as _,
+            Self::v8_fn_ptr_metrics as _,
+            None,
+            None,
+            ::deno_core::OpMetadata {
+                ..::deno_core::OpMetadata::default()
+            },
+        );
+    }
+    impl op_from_v8_arg {
+        pub const fn name() -> &'static str {
+            stringify!(op_from_v8_arg)
+        }
+        #[inline(always)]
+        fn slow_function_impl(
+            info: *const deno_core::v8::FunctionCallbackInfo,
+        ) -> usize {
+            #[cfg(debug_assertions)]
+            let _reentrancy_check_guard = deno_core::_ops::reentrancy_check(
+                &<Self as deno_core::_ops::Op>::DECL,
+            );
+            let mut scope = unsafe { deno_core::v8::CallbackScope::new(&*info) };
+            let mut rv = deno_core::v8::ReturnValue::from_function_callback_info(unsafe {
+                &*info
+            });
+            let args = deno_core::v8::FunctionCallbackArguments::from_function_callback_info(unsafe {
+                &*info
+            });
+            let result = {
+                let arg0 = args.get(0usize as i32);
+                let arg0 = match <Foo as deno_core::FromV8>::from_v8(&mut scope, arg0) {
+                    Ok(t) => t,
+                    Err(arg0_err) => {
+                        let msg = deno_core::v8::String::new(
+                                &mut scope,
+                                &format!("{}", deno_core::anyhow::Error::from(arg0_err)),
+                            )
+                            .unwrap();
+                        let exc = deno_core::v8::Exception::type_error(&mut scope, msg);
+                        scope.throw_exception(exc);
+                        return 1;
+                    }
+                };
+                Self::call(arg0)
+            };
+            deno_core::_ops::RustToV8RetVal::to_v8_rv(result, &mut rv);
+            return 0;
+        }
+        extern "C" fn v8_fn_ptr(info: *const deno_core::v8::FunctionCallbackInfo) {
+            Self::slow_function_impl(info);
+        }
+        extern "C" fn v8_fn_ptr_metrics(
+            info: *const deno_core::v8::FunctionCallbackInfo,
+        ) {
+            let args = deno_core::v8::FunctionCallbackArguments::from_function_callback_info(unsafe {
+                &*info
+            });
+            let opctx = unsafe {
+                &*(deno_core::v8::Local::<deno_core::v8::External>::cast(args.data())
+                    .value() as *const deno_core::_ops::OpCtx)
+            };
+            deno_core::_ops::dispatch_metrics_slow(
+                &opctx,
+                deno_core::_ops::OpMetricsEvent::Dispatched,
+            );
+            let res = Self::slow_function_impl(info);
+            if res == 0 {
+                deno_core::_ops::dispatch_metrics_slow(
+                    &opctx,
+                    deno_core::_ops::OpMetricsEvent::Completed,
+                );
+            } else {
+                deno_core::_ops::dispatch_metrics_slow(
+                    &opctx,
+                    deno_core::_ops::OpMetricsEvent::Error,
+                );
+            }
+        }
+    }
+    impl op_from_v8_arg {
+        #[inline(always)]
+        pub fn call(foo: Foo) {
+            let _ = foo;
+        }
+    }
+    <op_from_v8_arg as ::deno_core::_ops::Op>::DECL
+}
diff --git a/ops/op2/test_cases/sync/from_v8.rs b/ops/op2/test_cases/sync/from_v8.rs
new file mode 100644
index 0000000..2649c51
--- /dev/null
+++ b/ops/op2/test_cases/sync/from_v8.rs
@@ -0,0 +1,23 @@
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+#![deny(warnings)]
+deno_ops_compile_test_runner::prelude!();
+use deno_core::v8;
+use deno_core::FromV8;
+
+struct Foo;
+
+impl<'a> FromV8<'a> for Foo {
+  type Error = std::convert::Infallible;
+  fn from_v8(
+    _scope: &mut v8::HandleScope<'a>,
+    _value: v8::Local<'a, v8::Value>,
+  ) -> Result<Self, Self::Error> {
+    Ok(Foo)
+  }
+}
+
+#[op2]
+#[from_v8]
+pub fn op_from_v8_arg(#[from_v8] foo: Foo) {
+  let _ = foo;
+}
diff --git a/ops/op2/test_cases/sync/to_v8.out b/ops/op2/test_cases/sync/to_v8.out
new file mode 100644
index 0000000..fe2451a
--- /dev/null
+++ b/ops/op2/test_cases/sync/to_v8.out
@@ -0,0 +1,99 @@
+#[allow(non_camel_case_types)]
+pub const fn op_to_v8_return() -> ::deno_core::_ops::OpDecl {
+    #[allow(non_camel_case_types)]
+    pub struct op_to_v8_return {
+        _unconstructable: ::std::marker::PhantomData<()>,
+    }
+    impl ::deno_core::_ops::Op for op_to_v8_return {
+        const NAME: &'static str = stringify!(op_to_v8_return);
+        const DECL: ::deno_core::_ops::OpDecl = ::deno_core::_ops::OpDecl::new_internal_op2(
+            ::deno_core::__op_name_fast!(op_to_v8_return),
+            false,
+            false,
+            0usize as u8,
+            Self::v8_fn_ptr as _,
+            Self::v8_fn_ptr_metrics as _,
+            None,
+            None,
+            ::deno_core::OpMetadata {
+                ..::deno_core::OpMetadata::default()
+            },
+        );
+    }
+    impl op_to_v8_return {
+        pub const fn name() -> &'static str {
+            stringify!(op_to_v8_return)
+        }
+        #[inline(always)]
+        fn slow_function_impl(
+            info: *const deno_core::v8::FunctionCallbackInfo,
+        ) -> usize {
+            #[cfg(debug_assertions)]
+            let _reentrancy_check_guard = deno_core::_ops::reentrancy_check(
+                &<Self as deno_core::_ops::Op>::DECL,
+            );
+            let mut scope = unsafe { deno_core::v8::CallbackScope::new(&*info) };
+            let mut rv = deno_core::v8::ReturnValue::from_function_callback_info(unsafe {
+                &*info
+            });
+            let result = { Self::call() };
+            match deno_core::_ops::RustToV8Fallible::to_v8_fallible(
+                deno_core::_ops::RustToV8Marker::<
+                    deno_core::_ops::ToV8Marker,
+                    _,
+                >::from(result),
+                &mut scope,
+            ) {
+                Ok(v) => rv.set(v),
+                Err(rv_err) => {
+                    let msg = deno_core::v8::String::new(
+                            &mut scope,
+                            &format!("{}", deno_core::anyhow::Error::from(rv_err)),
+                        )
+                        .unwrap();
+                    let exc = deno_core::v8::Exception::type_error(&mut scope, msg);
+                    scope.throw_exception(exc);
+                    return 1;
+                }
+            };
+            return 0;
+        }
+        extern "C" fn v8_fn_ptr(info: *const deno_core::v8::FunctionCallbackInfo) {
+            Self::slow_function_impl(info);
+        }
+        extern "C" fn v8_fn_ptr_metrics(
+            info: *const deno_core::v8::FunctionCallbackInfo,
+        ) {
+            let args = deno_core::v8::FunctionCallbackArguments::from_function_callback_info(unsafe {
+                &*info
+            });
+            let opctx = unsafe {
+                &*(deno_core::v8::Local::<deno_core::v8::External>::cast(args.data())
+                    .value() as *const deno_core::_ops::OpCtx)
+            };
+            deno_core::_ops::dispatch_metrics_slow(
+                &opctx,
+                deno_core::_ops::OpMetricsEvent::Dispatched,
+            );
+            let res = Self::slow_function_impl(info);
+            if res == 0 {
+                deno_core::_ops::dispatch_metrics_slow(
+                    &opctx,
+                    deno_core::_ops::OpMetricsEvent::Completed,
+                );
+            } else {
+                deno_core::_ops::dispatch_metrics_slow(
+                    &opctx,
+                    deno_core::_ops::OpMetricsEvent::Error,
+                );
+            }
+        }
+    }
+    impl op_to_v8_return {
+        #[inline(always)]
+        pub fn call() -> Foo {
+            Foo
+        }
+    }
+    <op_to_v8_return as ::deno_core::_ops::Op>::DECL
+}
diff --git a/ops/op2/test_cases/sync/to_v8.rs b/ops/op2/test_cases/sync/to_v8.rs
new file mode 100644
index 0000000..3a26df9
--- /dev/null
+++ b/ops/op2/test_cases/sync/to_v8.rs
@@ -0,0 +1,23 @@
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+#![deny(warnings)]
+deno_ops_compile_test_runner::prelude!();
+use deno_core::v8;
+use deno_core::ToV8;
+
+struct Foo;
+
+impl<'a> ToV8<'a> for Foo {
+  type Error = std::convert::Infallible;
+  fn to_v8(
+    self,
+    scope: &mut v8::HandleScope<'a>,
+  ) -> Result<v8::Local<'a, v8::Value>, Self::Error> {
+    Ok(v8::null(scope).into())
+  }
+}
+
+#[op2]
+#[to_v8]
+pub fn op_to_v8_return() -> Foo {
+  Foo
+}
diff --git a/serde_v8/tests/de.rs b/serde_v8/tests/de.rs
index ef5a1b2..ff0101e 100644
--- a/serde_v8/tests/de.rs
+++ b/serde_v8/tests/de.rs
@@ -115,13 +115,13 @@ defail!(
   de_tuple_wrong_len_short,
   (u64, bool, ()),
   "[123, true]",
-  |e| e == Err(Error::LengthMismatch(2, 3))
+  |e| matches!(e, Err(Error::LengthMismatch(2, 3)))
 );
 defail!(
   de_tuple_wrong_len_long,
   (u64, bool, ()),
   "[123, true, null, 'extra']",
-  |e| e == Err(Error::LengthMismatch(4, 3))
+  |e| matches!(e, Err(Error::LengthMismatch(4, 3)))
 );
 detest!(
   de_mathop,
@@ -417,8 +417,10 @@ detest!(
   serde_json::json!({})
 );
 
-defail!(defail_struct, MathOp, "123", |e| e
-  == Err(Error::ExpectedObject("Number")));
+defail!(defail_struct, MathOp, "123", |e| matches!(
+  e,
+  Err(Error::ExpectedObject("Number"))
+));
 
 #[derive(Eq, PartialEq, Debug, Deserialize)]
 pub struct SomeThing {
@@ -437,8 +439,10 @@ detest!(
 );
 
 detest!(de_bstr, ByteString, "'hello'", "hello".into());
-defail!(defail_bstr, ByteString, "'👋bye'", |e| e
-  == Err(Error::ExpectedLatin1));
+defail!(defail_bstr, ByteString, "'👋bye'", |e| matches!(
+  e,
+  Err(Error::ExpectedLatin1)
+));
 
 #[derive(Eq, PartialEq, Debug, Deserialize)]
 pub struct StructWithBytes {