# How to turn an exploratory REPL session into a script

> Convert a REPL history into a script that runs, by dropping the lines that threw, the expressions typed to look at a value, and the REPL's own commands.

Source: https://voxgig.com/howto/save-a-repl-session-as-a-script

- Audience: api-consumer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Keep the declarations and the calls. Drop three things: any line that threw when you typed it, any bare expression typed only to see a value, and the REPL's own dot commands. Where a name was declared twice, keep the later version in the position the first one held. Then read the result, because a working session is not the same as a script you would commit.

---
## You will need

Node 22 or later, and a REPL session whose history you kept. Node writes one by default to the path
[NODE_REPL_HISTORY](https://nodejs.org/api/repl.html#environment-variable-options) names,[^1] and
`.save` writes the current session to a file if you remember to run it before you exit. Python's
[readline history](https://docs.python.org/3/library/readline.html) works the same way, and so does
the conversion.[^2]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [.save in the Node REPL](https://nodejs.org/api/repl.html#commands-and-special-keys) | You remember to run it, and the session was mostly clean | Everything you typed, failures included, so the file rarely runs as written | The session had false starts, which is most sessions |
| [Converting the history file](https://nodejs.org/api/repl.html#persistent-history) | Any session, including the ones you did not plan to keep | A conversion that guesses, and output you have to read before trusting | You want the script to be what you would have written |
| [Copying from the terminal](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API) | Three or four lines that you can see on one screen | Prompts and output mixed into the code, removed by hand | The session is longer than a screen |
| [Writing the script from scratch](https://nodejs.org/api/modules.html) | The exploration taught you something and the code is worth rewriting | The twenty minutes again, from a memory that is already fading | You want the thing that worked, verbatim |

The conversion is a first draft and it is worth being clear about that. What it gives you is every
line that ran, with the noise gone, which is a much better starting point than a blank file and a
memory. What it does not give you is a script you should commit unread.

`.save` and a history conversion differ in when you have to have decided.[^3] `.save` needs the thought
before you exit; a history file is there whether or not you planned anything. Given that the whole
situation starts with an unplanned session, the second is the one that works in practice.

## Drop the three kinds of noise

Three rules, each aimed at a different thing a REPL collects.

```js title="convert.mjs"
if (line.startsWith('.')) {
  dropped.push({ line, why: 'REPL command' })
  continue
}
if (ran(line) === false) {
  dropped.push({ line, why: 'threw when it was typed' })
  continue
}
if (isBareExpression(line)) {
  dropped.push({ line, why: 'typed to look at a value' })
  continue
}
```

Report what was dropped and why. A conversion that silently removes a line you needed is worse than
one that keeps too much, and the list is how you check its work in five seconds.

Bare expressions are the interesting case. In a REPL, typing `page.data.length` prints a number and
is the point of typing it. In a script it computes a value and discards it, which is dead code that
looks like an intention.

## Keep the version that worked

A name declared twice keeps its later definition.

```js title="convert.mjs"
function dedupe(lines) {
  const byName = new Map()
  const order = []
  for (const line of lines) {
    const name = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line)?.[1]
    if (!name) { order.push({ line }); continue }
    if (byName.has(name)) byName.get(name).line = line
    else {
      const entry = { line }
      byName.set(name, entry)
      order.push(entry)
    }
  }
  return order.map((e) => e.line)
}
```

Keeping the first position and the last definition is what makes the result run. A redeclaration
moved to the end would leave every reference between the two reading an older value. In a module a
second `const` with the same name is a syntax error rather than a shadow.

## Check it worked

Convert a session that had three false starts in it.

```bash
node demo.mjs
```

```text output
dropped
  threw when it was typed    res.staus
  typed to look at a value   res.status
  threw when it was typed    page.data.lenght
  typed to look at a value   page.data.length
  typed to look at a value   ids
  threw when it was typed    const serials = page.data.map(m => m.serail)
  REPL command               .exit

script
  const base = 'http://127.0.0.1:0'
  const key = 'sk_live_1'
  await fetch(base + '/meters')
  const res = await fetch(base + '/meters', { headers: { authorization: 'Bearer ' + key } })
  const page = await res.json()
  const ids = page.data.map(m => m.id)
  const serials2 = page.data.map(m => m.serial)
  console.log(ids, serials2)
```

Eight lines from a history of fifteen, and the three typos are gone. Read the script before you keep
it, because it still carries the evidence of how it was made. Line three is an unauthenticated probe
from when you were checking the endpoint existed. Line two holds a live key that belongs in the
environment. The last variable is called `serials2` because the first attempt failed.

The dropped list is the other half of the evidence. It gives the reason for each of the seven lines
that went. A conversion you cannot audit is a conversion you will stop using the first time it
removes something you wanted.

```bash
node --test convert.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 110.048599
```

## When it goes wrong

The script fails on a name that does not exist. A declaration was typed as a bare expression, or the
REPL had it from an earlier session. Run the script in a clean process before you trust it. A REPL
carries everything you ever typed, and a script carries only what you kept.

Multiline input becomes several broken lines. History files store what you typed per line, and a
function body spans several. Join continuation lines before converting, or keep multiline work in a
file from the start. A function you are still shaping belongs in an editor, and the REPL is for
calling it.

A credential ends up in the script. You typed it in the REPL, so it is in the history file too. Move
it to an environment variable, and consider whether the history file itself needs deleting.

Nothing survives the conversion. The session was all inspection and no declarations. That is a
finding about the session rather than the tool. It usually means the work was reading rather than
building, and there was nothing to save.

## When not to do this

Do not commit a converted session without reading it. The output is a transcript of how you learned,
and a transcript is not a design. Rename the variables, remove the exploratory calls, and it becomes
a script somebody else can read. Ten minutes of that is the difference between a note to yourself
and something the team can use.

Do not convert a session that touched production. The lines that ran are exactly the lines you would
rather not re-run by accident, and a file makes that one keystroke away. Convert it, read it, then
decide whether it should exist at all.

Do not treat the history file as private. It is a plain file, it is often backed up, and it holds
every key you pasted while exploring.

## Related how-tos

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Loop over every page of a REST collection](/howto/loop-over-every-page-of-a-collection)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: The default file is `.node_repl_history` in the home directory, and
[`NODE_REPL_HISTORY_SIZE`](https://nodejs.org/api/repl.html#environment-variable-options) keeps
the last 1000 lines of it. Setting `NODE_REPL_HISTORY` to an empty string switches the history
off, except on Windows, where an environment variable with an empty value is invalid. The
documentation's remedy is to set it to one or more spaces, and it offers this without further
comment.

[^2]: Python has saved its history to `~/.python_history` since 3.4, when the
[site module](https://docs.python.org/3/library/site.html#rlcompleter-config) made the behavior
automatic. The [readline module](https://docs.python.org/3/library/readline.html) adds a note
for macOS, where the library underneath may be `libedit` rather than GNU readline. The two may
use different history file formats, and switching between them may leave the existing file
unusable. The same way, then, up to the point where it is not.

[^3]: The Node REPL has seven
[special commands](https://nodejs.org/api/repl.html#commands-and-special-keys): `.break`,
`.clear`, `.exit`, `.help`, `.save`, `.load` and `.editor`, and one of the seven exists to show
the list of the other six. `.save` and `.load` are documented as a pair, each with a one-line
example. `.editor` gets the longest entry, in which a function greets a Node.js user and Ctrl+D
finishes the job.