#3476·napi-rs

Nested namespaces are broken at run time

Author: Chaoses-IbCreated Sep 2, 2026Updated Sep 2, 2026

During migrating from wasm-bindgen to napi, I encountered a problem about nested namespaces. napi doc says #[napi] mod namespaces cannot be nested, but doesn't clarify what about #[napi(namespace = "...")]:

  • #[napi] mod name { ... } — export an inline Rust module as a namespace. Every child item that also carries #[napi] is exported inside it (nested napi modules are not supported). Add #[napi(js_name = "...")] on the mod to rename the namespace object.
  • #[napi(namespace = "...")] on individual functions, classes, impl blocks, enums, consts, and type aliases — registers that item under exports.<namespace>; apply the same namespace to a class and its impl blocks. See namespace in the attributes reference.

And nested namespaces like #[napi(namespace = "a.b")] actually work for the generated index.js/index.d.ts, but not for the runtime. It looks the runtime will mount it literally like exports["a.b"]. Adding a #[napi(module_exports)] hook can work around this problem. But it looks better to fix this in napi?

By the way, cross-namespace type reference cannot generate correct index.d.ts. Adding manual TS types like #[napi(ts_type = "a.MyType")] can fix it. Is this expected or a bug too?

The workaround hook:

rust
#[napi(module_exports)]
unsafe fn restructure_namespaces(
    env: &napi::bindgen_prelude::Env,
    mut exports: napi::bindgen_prelude::Object,
) -> napi::Result<()> {
    use napi::bindgen_prelude::{JsObjectValue, Object};

    for key in Object::keys(&exports)? {
        if !key.contains('.') {
            continue;
        }
        let parts: Vec<&str> = key.split('.').collect();
        let Some(flat) = exports.get::<Object>(key.as_str())? else {
            continue;
        };
        let mut current = exports;
        for part in &parts[..parts.len() - 1] {
            let next = match current.get::<Object>(*part)? {
                Some(next) => next,
                None => {
                    let next = Object::new(env)?;
                    current.set(*part, &next)?;
                    next
                }
            };
            current = next;
        }
        current.set(parts[parts.len() - 1], &flat)?;
        exports.delete_named_property(key.as_str())?;
    }
    Ok(())
}