Skip to content

deno repl: load a module and inspect its exports and top-level locals in one command #36272

Description

@izelnakri

Summary

deno repl should be able to load a module and drop you into its scope — with both its exports and its top-level locals live and inspectable — in one command. Today this is almost possible, which is what makes it worth closing the gap: the primitives (--eval, --eval-file, native TS, native ESM import, top-level await) are all there, but they don't compose into "open this module and let me poke at everything inside it."

Motivation — inspectability is a first-class DX feature

Deno's REPL is already one of the best in the JS world: TypeScript with no setup, import statements and top-level await that Just Work, and --eval/--eval-file bootstrapping. That's clearly effort in the right direction.

The thing that would make it great is the workflow the Ruby ecosystem set the bar for. Given Deno's lineage — Ryan Dahl's roots are in the Ruby/Rails world, where rails console, irb, and binding.pry treat live inspection of your actual runtime state as a primary way to develop — this feels squarely aligned with Deno's DX-first values. In that culture you don't just run code; you drop into it, list what's in scope, and interrogate real values. That tight "load → inspect → tweak" loop is a genuine productivity multiplier, and JS runtimes have historically been weak at it (Node makes even the basic version painful — see the appendix).

Deno is uniquely positioned to ship the best-in-class version of this because it already owns the loader, the TS pipeline, and the permission model.

The gap today

Starting from a module and wanting to explore it interactively runs into three walls:

  1. Exports aren't exposed as bindings. deno repl --eval-file=mod.ts on a re-export barrel (export { x } from "./x.ts") leaves you with nothing in scope — re-exports create no local binding, so there's no x to inspect. You have to hand-write const $ = await import("./mod.ts") yourself.

  2. Locals aren't enumerable. With --eval-file, a module's top-level const one = … is reachable by name in the REPL, but it's a lexical binding, not a globalThis property — so there is no way to answer "what locals are in scope?" Object.keys(globalThis) and Reflect.ownKeys(globalThis) don't see them, and JS offers no reflection over lexical scope. You can only inspect names you already know.

  3. --eval-file resolves relative specifiers against CWD, not the file. Running deno repl --eval-file=lib/result/index.ts fails with Module not found file:///…/result.ts because its ./result.ts import is resolved from the working directory instead of the file's own location. (This one is arguably just a bug.)

Net effect: you can get exports or locals, never cleanly both, and never a list of what's actually there.

Proposal

Some combination of:

  • deno repl <specifier> (positional) — load the module through the normal graph and expose its exports as top-level bindings, plus a namespace handle (e.g. $ or _module). Works for local paths, file:, https:, jsr:, npm:.
  • A scope-introspection builtin — the missing primitive. Something like a REPL meta-command .scope (or a Deno[Deno.internal].locals() style call) that lists the current lexical bindings and the loaded module's top-level locals. This is the piece no amount of userland code can do today, because lexical scope isn't reflectable — only the runtime can surface it.
  • Optionally expose a loaded module's non-exported top-level locals when it's the entry you asked to inspect (this is the binding/pry equivalent — see what the module actually declared, not just its public surface).
  • Fix --eval-file relative-specifier resolution to be relative to the file.

Concrete sketch:

$ deno repl ./lib/result/index.ts
> $                       # the module's export namespace
> ok(42)                  # each export bound directly
> .scope                  # <-- the new capability: list everything in scope
ok, err, isOk, …, Failure   (exports)
$ deno repl ./scratch.ts  # a file with top-level `const one = …, two = …`
> .scope
one, two                    (module locals)
> one

Prior art

  • Ruby: irb, pry, binding.pry — drop into the exact binding, ls to list methods/locals, inspect live objects. The gold standard for "interrogate your running program."
  • Python: python -i script.py drops into a REPL with the script's module namespace populated; code.interact(local=locals()) hands an explicit scope to an interactive session.
  • Node: no first-class equivalent; the community workarounds are genuinely bad (appendix).

Proof of concept

I built a working userland prototype to validate the ergonomics. It:

  1. imports the target for its exports (spread into scope as $),
  2. parses the module with the TypeScript compiler API to enumerate every top-level value binding (const/let/var incl. destructuring, function, class, enum, namespace, and import bindings; type-only skipped),
  3. rewrites the module's relative import specifiers to absolute so an instrumented copy runs from anywhere, and appends a globalThis.locals() capturer,
  4. runs that via --eval-file alongside the --eval import.

It works for local paths, file:/https:/jsr: refs (npm: ESM too), giving both $ (exports) and locals() (every top-level binding, with live values). That it takes an AST pass + specifier rewriting + a temp file to approximate this is exactly the argument for it living in the runtime — where the scope is already known and none of the workarounds are needed.

Happy to share the prototype or help prototype the real thing: https://gist.github.com/izelnakri/e2bba885ab513d78cd54f3a01970ccc3

Appendix — why Node can't just do this

Node's nearest approximation is node -i -e "<code>", but any import statement forces strict ESM mode where top-level bindings never escape the module, so nothing lands in the REPL. The only way to get locals is to rewrite every import to require, keeping the file CJS/sloppy so consts leak to the global object — a hack that breaks on ESM-only deps and top-level await. Deno's native ESM + TS + loader means it can do the real thing instead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions