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”.
sedmangles the escape, andnode -eputs 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,\uin a replacement means “uppercase the next character”, not a codepoint escape.gsedwould give you00A0. 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
- Write the file with an ASCII placeholder where the escape belongs.
- Replace it from a
.cjsscript file, notnode -e— a script file has only one escaping layer, and building the backslash withString.fromCharCode(92)removes even that. - 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.tsod -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.tsThe 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.
Related
- nbsp-testing-library-normalizer — the sibling trap: correct NBSP bytes in the DOM that a test still cannot match
- muetal — the project where both of these surfaced
- shell-gotchas-pgrep-self-match — another macOS shell trap that fails silently