A text-to-binary function can pass every test with Hello and still produce the wrong bytes for 🙂. The catch is deciding what “binary” means. For this article, the target is UTF-8 bytes, displayed as eight-bit groups. JavaScript string operations don't automatically give you those bytes. Consider this implementation: function naiveBinary(text) { return text.split("") .map(char => char.charCodeAt(0).toString(2).padStart(8, "0")) .join(" "); } console.log(naiveBinary("A")); // 01000001 console.log(naiveBinary("🙂")); // 1101100000111101 1101111001000010 The second result contains two 16-bit groups. Neither is a UTF-8 byte. Adding padStart(8) doesn't fix it: padding sets a minimum width; it doesn't convert an encoding. What charCodeAt actually returns JavaScript strings are sequences of UTF-16 code units. charCodeAt() reads one of those units. The emoji 🙂, whose Unicode code point is U+1F642, uses a surrogate pair: 0xD83D and 0xDE42. Splitting with split("") separates that pair. See the MDN charCodeAt documentation. You can inspect the difference directly: console.log("🙂".length); // 2 UTF-16 code units console.log([..."🙂"].length); // 1 Unicode code point console.log("🙂".codePointAt(0).toString(16)); // "1f642" console.log([...new TextEncoder().encode("🙂")]); // [240, 159, 153, 130] Switching to codePointAt() retrieves the full code point, but converting that number to base two still doesn't produce UTF-8. A code point identifies a Unicode character; an encoding determines how it becomes bytes. MDN explains codePointAt and string iteration here. This also affects text without emoji. Here are four concrete examples: Text UTF-16 code units Unicode code points UTF-8 bytes, in hexadecimal A 1 1 41 é 1 1 C3 A9 中 1 1 E4 B8 AD 🙂 2 1 F0 9F 99 82 For é, the naive function returns 11101001. That's the numeric value U+00E9 written in binary. Its UTF-8 representation requires two bytes: 11000011 10101001. Encode first, format second TextEncoder.encode() produces a Uint8Array containing UTF-8 bytes. Formatting each of those bytes is then straightforward: function textToBinary(text) { return Array.from( new TextEncoder().encode(text), byte => byte.toString(2).padStart(8, "0") ).join(" "); } console.log(textToBinary("🙂")); // 11110000 10011111 10011001 10000010 console.log(textToBinary("é")); // 11000011 10101001 Now padding to eight bits makes sense: each value is a byte between 0 and 255. The spaces are a display convention. They make byte boundaries visible; they aren't part of the encoded text. Decode bytes together, and reject malformed input The reverse operation needs two checks: valid binary groups and valid UTF-8. This example accepts whitespace-separated groups of exactly eight bits. Empty input returns an empty string. function binaryToText(binary) { const input = binary.trim(); if (!input) return ""; const groups = input.split(/\s+/); if (groups.some(group => !/^[01]{8}$/.test(group))) { throw new Error("Expected whitespace-separated 8-bit binary groups."); } const bytes = Uint8Array.from( groups, group => Number.parseInt(group, 2) ); return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } console.log(binaryToText(textToBinary("Hello, 中 🙂"))); // Hello, 中 🙂 Decode the complete byte array in one call. Decoding each byte separately would break characters whose UTF-8 encoding spans multiple bytes. With fatal: true, malformed UTF-8 throws instead of silently becoming replacement characters. For a converter, that lets the UI report an encoding mismatch or damaged input. The name ignoreBOM is easy to misread: setting it to true preserves a leading U+FEFF in the decoded string instead of stripping it as a byte-order mark. Test expected bytes, not just round trips Two functions can share the same mistake and still round-trip successfully. Check known output as well: console.assert(textToBinary("A") === "01000001"); console.assert(textToBinary("é") === "11000011 10101001"); console.assert(textToBinary("中") === "11100100 10111000 10101101"); console.assert( textToBinary("🙂") === "11110000 10011111 10011001 10000010" ); console.assert(binaryToText(textToBinary("Hello, 中 🙂")) === "Hello, 中 🙂"); for (const input of ["0100000", "0100000x", "11111111"]) { let rejected = false; try { binaryToText(input); } catch { rejected = true; } console.assert(rejected, `Should reject: ${input}`); } The final invalid case has eight valid binary digits, but 0xFF is not a valid UTF-8 byte. Checking group length alone would miss it. One boundary to keep explicit: JavaScript strings can contain lone surrogates. TextEncoder replaces those with U+FFFD, so this approach is intended for well-formed Unicode text, not lossless storage of every possible JavaScript string. The conversion follows the Encoding Standard. Applying this in a browser tool My project, Binary Code Translator, uses this TextEncoder approach for its UTF-8 mode. It also has explicit ASCII modes that reject characters outside the ASCII range, rather than returning output that looks plausible but uses the wrong encoding. That distinction belongs in the interface as well as the implementation. Label the encoding, show byte boundaries, and explain decoding errors. A row of zeros and ones alone doesn't tell the reader how to interpret it.