JavaScript
rot13
coding error
debugging
string manipulation

Where is my implementation of rot13 in JavaScript going wrong?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

ROT13 shifts each letter by 13 positions in the alphabet, wrapping around from Z back to A. Common bugs in JavaScript implementations include not handling uppercase and lowercase separately, applying the shift to non-alphabetic characters (digits, punctuation), using incorrect character code ranges, and off-by-one errors in the wrapping logic. A correct implementation processes only A-Z and a-z while passing everything else through unchanged.

Correct Implementation

javascript
1function rot13(str) {
2    return str.replace(/[a-zA-Z]/g, function(char) {
3        const base = char <= 'Z' ? 65 : 97;  // 'A' = 65, 'a' = 97
4        return String.fromCharCode(
5            ((char.charCodeAt(0) - base + 13) % 26) + base
6        );
7    });
8}
9
10console.log(rot13("Hello World!"));   // "Uryyb Jbeyq!"
11console.log(rot13("Uryyb Jbeyq!"));   // "Hello World!"  (ROT13 is its own inverse)

The key formula: ((charCode - base + 13) % 26) + base

  • Subtract base to get 0-25
  • Add 13
  • Modulo 26 to wrap around
  • Add base back to get the ASCII code

Bug 1: Not Preserving Case

javascript
1// WRONG: converts everything to uppercase
2function rot13Bad(str) {
3    return str.replace(/[a-zA-Z]/g, function(char) {
4        return String.fromCharCode(
5            ((char.toUpperCase().charCodeAt(0) - 65 + 13) % 26) + 65
6        );
7    });
8}
9
10rot13Bad("Hello");  // "URYYB", lost lowercase

Fix: Detect the case and use the appropriate base (65 for uppercase, 97 for lowercase):

javascript
const base = char.charCodeAt(0) < 97 ? 65 : 97;

Bug 2: Transforming Non-Alphabetic Characters

javascript
1// WRONG: shifts digits and punctuation too
2function rot13Bad(str) {
3    let result = '';
4    for (let i = 0; i < str.length; i++) {
5        result += String.fromCharCode(str.charCodeAt(i) + 13);
6    }
7    return result;
8}
9
10rot13Bad("Hello 123!");  // "Uryy|-0./4", garbage for non-letters

Fix: Check if the character is a letter before shifting:

javascript
1function rot13(str) {
2    let result = '';
3    for (let i = 0; i < str.length; i++) {
4        const code = str.charCodeAt(i);
5        if (code >= 65 && code <= 90) {
6            result += String.fromCharCode(((code - 65 + 13) % 26) + 65);
7        } else if (code >= 97 && code <= 122) {
8            result += String.fromCharCode(((code - 97 + 13) % 26) + 97);
9        } else {
10            result += str[i];  // Pass through unchanged
11        }
12    }
13    return result;
14}

Bug 3: Wrong Wrapping Logic

javascript
1// WRONG: uses simple addition without modulo
2function rot13Bad(str) {
3    return str.replace(/[a-zA-Z]/g, function(char) {
4        const code = char.charCodeAt(0);
5        if (code >= 65 && code <= 90) {
6            return String.fromCharCode(code + 13);  // No wrapping!
7        }
8        return char;
9    });
10}
11
12rot13Bad("XYZ");  // "efg", shifted past 'Z' into lowercase range

Without % 26, letters past M/m overflow into non-letter ASCII ranges. X (88) + 13 = 101 = e (wrong, should be K).

Bug 4: Off-by-One in Range Check

javascript
1// WRONG: excludes 'Z' and 'z'
2function rot13Bad(str) {
3    return str.replace(/[a-zA-Z]/g, function(char) {
4        const code = char.charCodeAt(0);
5        if (code >= 65 && code < 90) {  // < instead of <=
6            return String.fromCharCode(((code - 65 + 13) % 26) + 65);
7        }
8        return char;
9    });
10}
11
12rot13Bad("AZ");  // "NZ", 'Z' is not rotated

Use <= for inclusive ranges: code >= 65 && code <= 90.

Bug 5: Using charCodeAt Without Arguments

javascript
1// WRONG: charCodeAt() defaults to index 0, but this is inside replace
2function rot13Bad(str) {
3    return str.split('').map(function(c) {
4        // c is a single character, so charCodeAt(0) is correct
5        // But some people write charCodeAt() without 0. It still works but is unclear
6        return String.fromCharCode(((c.charCodeAt() - 97 + 13) % 26) + 97);
7    }).join('');
8}
9
10rot13Bad("Hello");  // Wrong: uses 97 base for everything, breaks uppercase

Alternative: Lookup Table

For clarity and performance, use a precomputed mapping:

javascript
1const ROT13_MAP = {};
2for (let i = 0; i < 26; i++) {
3    const upper = String.fromCharCode(65 + i);
4    const lower = String.fromCharCode(97 + i);
5    ROT13_MAP[upper] = String.fromCharCode(65 + ((i + 13) % 26));
6    ROT13_MAP[lower] = String.fromCharCode(97 + ((i + 13) % 26));
7}
8
9function rot13(str) {
10    return str.replace(/[a-zA-Z]/g, char => ROT13_MAP[char]);
11}
12
13console.log(rot13("The Quick Brown Fox"));
14// "Gur Dhvpx Oebja Sbk"

Testing Your Implementation

ROT13 is its own inverse. Applying it twice returns the original:

javascript
1function testRot13(impl) {
2    const tests = [
3        ["Hello World!", "Uryyb Jbeyq!"],
4        ["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
5         "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm"],
6        ["123 !@#", "123 !@#"],  // Non-letters unchanged
7        ["", ""],                  // Empty string
8        ["AaBbZz", "NnOoMm"],
9    ];
10
11    tests.forEach(([input, expected]) => {
12        const result = impl(input);
13        const pass = result === expected;
14        console.log(`${pass ? 'PASS' : 'FAIL'}: rot13("${input}") = "${result}"`);
15
16        // Double rot13 should return original
17        const roundTrip = impl(impl(input));
18        console.assert(roundTrip === input, `Round trip failed for "${input}"`);
19    });
20}
21
22testRot13(rot13);

Other Cipher Variants

javascript
1// Generic Caesar cipher with any shift
2function caesarCipher(str, shift) {
3    shift = ((shift % 26) + 26) % 26;  // Normalize negative shifts
4    return str.replace(/[a-zA-Z]/g, function(char) {
5        const base = char <= 'Z' ? 65 : 97;
6        return String.fromCharCode(((char.charCodeAt(0) - base + shift) % 26) + base);
7    });
8}
9
10caesarCipher("Hello", 13);   // "Uryyb" (ROT13)
11caesarCipher("Hello", 1);    // "Ifmmp" (ROT1)
12caesarCipher("Ifmmp", -1);   // "Hello" (decrypt ROT1)

Common Pitfalls

  • Hardcoding ASCII values wrong: 'A' is 65, 'Z' is 90, 'a' is 97, 'z' is 122. Off-by-one in these constants breaks the entire cipher.
  • Forgetting modulo 26: Without % 26, letters in the second half of the alphabet (N-Z) produce character codes outside the letter range.
  • Treating uppercase and lowercase identically: Using a single base (65 or 97) for both cases corrupts the output. Detect the case per character.
  • Mutating non-letter characters: Digits, spaces, and punctuation must pass through unchanged. Only match [a-zA-Z].
  • Not testing round-trip: ROT13 applied twice must return the original string. If rot13(rot13(x)) !== x, the implementation is wrong.

Summary

  • ROT13 shifts letters by 13 positions: ((code - base + 13) % 26) + base
  • Use % 26 to wrap around the alphabet boundary
  • Handle uppercase (base 65) and lowercase (base 97) separately
  • Pass non-alphabetic characters through unchanged
  • ROT13 is its own inverse, meaning rot13(rot13(text)) === text
  • Use str.replace(/[a-zA-Z]/g, fn) for the cleanest implementation

Course illustration
Course illustration

All Rights Reserved.