String Tricks: Anagrams, Vowels, Masking, Extension Check, Find Duplicates, Extract Numbers
String Tricks: Anagrams, Vowels, Masking, Extension Check, Find Duplicates, Extract Numbers
A grab-bag of small string utilities pulled from a much larger pool: inspecting strings (anagram check, find duplicate characters, distinguish literal from object), counting and scanning (vowels via regex, extract numbers, extension check), transforming (mask the middle, generate alphabet ranges), and order-aware tricks (remove adjacent duplicates, reverse only words longer than n). Each is short on its own; together they cover most of the string work that shows up in real code.
906 views
16
// Case-insensitive anagram check: same characters, same counts.
const isAnagram = (a, b) => {
const norm = (s) => s.toLowerCase().split('').sort().join('');
return norm(a) === norm(b);
};
console.log(isAnagram('Listen', 'silent')); // true
console.log(isAnagram('hello', 'world')); // false
console.log(isAnagram('a gentleman', 'elegant man')); // false (whitespace counts)
// Find duplicate characters in a string with their counts.
const findDuplicateChars = (s) => {
const counts = new Map();
for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
const out = {};
for (const [ch, n] of counts) if (n > 1) out[ch] = n;
return out;
};
console.log(findDuplicateChars('programming'));
// { r: 2, g: 2, m: 2 }
// Distinguish a string from a String object (the boxed wrapper).
const isStringPrimitive = (x) => typeof x === 'string';
const isStringValue = (x) => typeof x === 'string' || x instanceof String;
console.log(isStringPrimitive('hi')); // true
console.log(isStringPrimitive(new String('hi'))); // false (it is an object)
console.log(isStringValue(new String('hi'))); // trueThe anagram check normalizes both strings (lowercase, sort characters, rejoin) and compares the results; whitespace counts as a character so 'a gentleman' is NOT an anagram of 'elegant man' until you strip spaces explicitly. The duplicate-character finder uses a Map to count occurrences in one pass and returns just the keys with counts above 1. The literal-vs-object distinction is rare but real: new String('hi') is a boxed object that survives typeof as 'object', so legacy code that uses new String needs the dual check (typeof === 'string' OR instanceof String). In modern code, never use new String; just use string literals.
// Count vowels with a single regex match; the //gi flag makes it global + case-insensitive.
const countVowels = (s) => (s.match(/[aeiou]/gi) ?? []).length;
console.log(countVowels('Hello World')); // 3
console.log(countVowels('JavaScript')); // 3
console.log(countVowels('rhythm')); // 0 (no aeiou; y not counted)
console.log(countVowels('')); // 0 (?? [] handles null match)
// Extract all integer runs from a string.
const extractNumbers = (s) =>
(s.match(/\d+/g) ?? []).map(Number);
console.log(extractNumbers('order #42 placed at 09:15 on day 3'));
// [42, 9, 15, 3]
console.log(extractNumbers('no digits here'));
// []
// Check that a filename ends with .txt (and only .txt).
const isTxt = (name) => /\.txt$/i.test(name);
console.log(isTxt('notes.txt')); // true
console.log(isTxt('NOTES.TXT')); // true (case-insensitive)
console.log(isTxt('notes.txt.bak')); // false
console.log(isTxt('archive.tar')); // false
// More general extension extractor.
const extension = (name) => name.split('.').pop().toLowerCase();
console.log(extension('photo.JPG')); // 'jpg'
console.log(extension('archive.tar.gz')); // 'gz'String.prototype.match returns an array of matches when the regex has the /g flag, or null if there are no matches; the ?? [] guard turns the null case into a clean zero. \d+ matches one-or-more digits and naturally splits a string into integer runs; map through Number to get real numeric values. The extension check uses an anchored regex ($) so notes.txt.bak correctly returns false. For a more general extension utility, splitting on . and taking the last part covers compound extensions like tar.gz only by returning gz; if you need tar.gz as a unit, walk the parts manually.
// Mask all but the last n characters with '#' using padStart.
const maskTail = (s, keepLast) => {
if (keepLast >= s.length) return s;
return s.slice(-keepLast).padStart(s.length, '#');
};
console.log(maskTail('1234567890', 4)); // '######7890'
console.log(maskTail('hello', 2)); // '###lo'
console.log(maskTail('hi', 5)); // 'hi' (kept whole, not enough chars)
// Same idea without padStart (manual repeat).
const maskTailManual = (s, keepLast) => {
if (keepLast >= s.length) return s;
const masked = '#'.repeat(s.length - keepLast);
return masked + s.slice(-keepLast);
};
console.log(maskTailManual('1234567890', 4)); // '######7890'
// Generate alphabet between two letters (inclusive).
const alphabetBetween = (a, b) => {
const start = a.charCodeAt(0);
const end = b.charCodeAt(0);
if (start > end) return [];
const out = [];
for (let code = start; code <= end; code++) {
out.push(String.fromCharCode(code));
}
return out;
};
console.log(alphabetBetween('a', 'e')); // ['a', 'b', 'c', 'd', 'e']
console.log(alphabetBetween('M', 'P')); // ['M', 'N', 'O', 'P']
console.log(alphabetBetween('z', 'a')); // [] (reversed range, empty)padStart is the built-in tool for left-padding to a target length: s.slice(-keepLast).padStart(s.length, '#') says "start with the last keepLast chars, then prepend hashes until the result matches the original length". The manual variant builds the prefix with String.repeat and concatenates; both produce the same output. Alphabet ranges work because letters have consecutive char codes within the same case ('a'..'z' is 97..122, 'A'..'Z' is 65..90). The empty-array return for reversed ranges is intentional; flip the bounds with Math.min/Math.max if you want to be permissive.
// Remove adjacent duplicate characters while preserving order.
// 'aabbccddeeffaa' -> 'abcdefa' (note the trailing 'a' survives because
// it is not adjacent to the earlier 'aa').
const removeAdjacentDuplicates = (s) => {
const out = [];
let prev = '';
for (const ch of s) {
if (ch !== prev) {
out.push(ch);
prev = ch;
}
}
return out.join('');
};
console.log(removeAdjacentDuplicates('aabbcc')); // 'abc'
console.log(removeAdjacentDuplicates('aabbccddeeffaa')); // 'abcdefa'
console.log(removeAdjacentDuplicates('mississippi')); // 'misisipi'
// Reverse only the words that have more than n characters.
const reverseLongWords = (sentence, n) =>
sentence
.split(' ')
.map((w) => (w.length > n ? w.split('').reverse().join('') : w))
.join(' ');
console.log(reverseLongWords('the quick brown fox jumps', 4));
// 'the kciuq nworb fox spmuj' (quick, brown, jumps reversed; 'the', 'fox' kept)
console.log(reverseLongWords('a bb ccc dddd eeeee', 3));
// 'a bb ccc dddd eeeee'.split(' ').map(...): only 'dddd' and 'eeeee' reverse
// -> 'a bb ccc dddd eeeee'? Actually 'dddd'.length === 4 > 3, reverse to 'dddd' (same).
// Let's verify with a trace:
console.log(reverseLongWords('hello world from javascript', 4));
// 'olleh dlrow from tpircsavaj' (hello>4 yes, world>4 yes, from no, javascript yes)The adjacent-duplicate remover walks the string keeping a single prev character; whenever the current character differs, it joins the output. This is O(n) and uses constant extra state (other than the output). Notice it removes only RUNS, so aabbccddeeffaa becomes abcdefa (the last aa collapses to a single a but the earlier a survives because they are not adjacent). The selective-word reverse splits on spaces, conditionally reverses each word that exceeds the length threshold, then rejoins. Use String.prototype.split(/\s+/) and a final join with a single space if you want to normalize multiple-space input; the simple ' ' split preserves whatever spacing was there.
