Writing an escape sequence such as '\u00A0' into a source file with sed or a node -e one-liner silently produces the wrong bytes on macOS. Both failure modes look correct on screen, because the difference is one invisible character or one backslash.

The trap

There is no shell tool here that “just works”. sed mangles the escape, and node -e puts two layers of escaping between what you type and what lands on disk (shell quoting, then JavaScript string literals). Off by one in either direction and you get a wrong file with no error.

Failure 1 — BSD sed eats the backslash

macOS ships BSD sed, not GNU sed. BSD sed does not implement \uXXXX in the replacement and simply drops the backslash. Verified on Darwin 25.5.0:

printf "const NBSP = 'PLACEHOLDER'\n" > t.ts
sed -i '' "s/PLACEHOLDER/\u00A0/" t.ts
# file now contains:  const NBSP = 'u00A0'

The result is the literal five-character string u00A0 — valid TypeScript, compiles fine, and every test that depends on the character fails for reasons that point nowhere near this line.

Switching to GNU sed does not fix it

In GNU sed, \u in a replacement means “uppercase the next character”, not a codepoint escape. gsed would give you 00A0. Neither dialect has a Unicode escape — reach for a different tool instead.

Failure 2 — node -e escaping, in both directions

Both of these are wrong, and which one you hit depends on your shell quoting. Verified with Node 22 under zsh:

# Double-quoted shell: shell turns \\ into \, then JS interprets \u00A0
node -e "require('fs').writeFileSync('t.ts', \"const NBSP = '\\u00A0'\")"
# → writes the RAW CHARACTER (byte a0) into the source file, not the escape
 
# Single-quoted shell: 4 backslashes survive the shell, JS collapses them to 2
node -e 'require("fs").writeFileSync("t.ts", "const NBSP = \"\\\\u00A0\"")'
# → writes TWO literal backslashes:  const NBSP = "\\u00A0"

The first produces a file that works but has an invisible character committed into source — it will survive until someone’s editor, linter, or copy-paste destroys it. The second produces the literal text \u00A0 (an escaped backslash), which never becomes the character.

The fix — placeholder + script file, then verify bytes

  1. Write the file with an ASCII placeholder where the escape belongs.
  2. Replace it from a .cjs script file, not node -e — a script file has only one escaping layer, and building the backslash with String.fromCharCode(92) removes even that.
  3. Verify the bytes. Do not trust the write.
// fix-escapes.cjs
const fs = require('fs')
const BS = String.fromCharCode(92)
const file = process.argv[2]
const s = fs.readFileSync(file, 'utf8').split('PLACEHOLDER').join(BS + 'u00A0')
fs.writeFileSync(file, s)
node fix-escapes.cjs src/lib/format.ts
 
# verify: no raw NBSP bytes where an escape was intended
od -c src/lib/format.ts | grep -n '302 240'   # c2 a0 = UTF-8 NBSP
# verify: no placeholders survived
grep -n PLACEHOLDER src/lib/format.ts

od -c renders multi-byte characters ambiguously on macOS. For a definitive check, print codepoints:

node -e "const s=require('fs').readFileSync(process.argv[1],'utf8');
console.log([...s].map(c=>c.codePointAt(0).toString(16)).join(' '))" src/lib/format.ts

The general lesson

Any invisible or escape-sensitive character written by tooling must be byte-verified after writing. This costs real debugging time precisely because the text looks correct on screen — in a diff, in an editor, in a terminal dump. The same applies to zero-width joiners, BOMs, narrow no-break space (U+202F), and soft hyphens.

The mirror-image trap is generating the character when you meant the escape text — it happens to LLM agents writing heredocs as readily as it happens to sed. Verify in both directions.