- Authors

- Name
- Alberto Montalesi
Converting a string to camelCase sounds like a one-liner, and for the easy
cases it is. The trouble starts when the input is not what you expected:
USER_NAME, XMLHttpRequest, a stray leading dash, two delimiters in a row.
This article covers both: the short version for when you control the input, and a version that holds up when you don't.
What camelCase actually is
Four naming conventions turn up constantly, and it helps to name them precisely before writing code to convert between them:
| Convention | Example |
|---|---|
| camelCase (lower camel case) | theStealthWarrior |
| PascalCase (upper camel case) | TheStealthWarrior |
| snake_case | the_stealth_warrior |
| kebab-case | the-stealth-warrior |
camelCase means: first word entirely lowercase, every following word
capitalised, no separators. PascalCase is identical except the first word is
capitalised too — which is why a lot of "camelCase" utilities actually
produce one from the other with a single Capitalize step.
The one-line version
If your input is reliably delimited by dashes, underscores or spaces, one
String.prototype.replace call is enough:
function toCamelCase(str) {
return str.replace(/[-_\s]+(.)?/g, (_, char) =>
char ? char.toUpperCase() : ''
)
}
toCamelCase('the-stealth-warrior')
// 'theStealthWarrior'
toCamelCase('The_Stealth_Warrior')
// 'TheStealthWarrior'
The regex does three things:
[-_\s]+matches a run of one or more dashes, underscores or whitespace characters. The+is what makesfoo--barwork — without it the second dash would survive into the output.(.)?optionally captures the character immediately after that run. It is optional so that a trailing delimiter ('foo-') still matches and gets removed rather than being left behind.- The replacer uppercases the captured character, or returns an empty string when there was nothing after the delimiter.
Note what this version does not do: it leaves the first character exactly
as it found it. 'The_Stealth_Warrior' comes back as 'TheStealthWarrior',
PascalCase, not camelCase. That is the behaviour the
CodeWars kata
asks for — I solved that one in
JS Challenge 6 — but it is
usually not what you want in application code. Force it:
function toCamelCase(str) {
return str
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
.replace(/^./, (char) => char.toLowerCase())
}
toCamelCase('The_Stealth_Warrior')
// 'theStealthWarrior'
Where the one-liner breaks
Feed it real-world input and the cracks show:
toCamelCase('USER_NAME')
// 'uSERNAME' — wanted 'userName'
toCamelCase('XMLHttpRequest')
// 'xMLHttpRequest' — wanted 'xmlHttpRequest'
The problem is structural, not a bug in the regex. The one-liner only knows
about delimiters. It has no concept of a word, so it cannot see that
USER_NAME is two words, and it cannot see any word boundary at all in
XMLHttpRequest because there are no delimiters to find.
The robust version: split into words first
The fix is to stop thinking in terms of "replace the delimiters" and start thinking in terms of "find the words, then join them". This is the approach Lodash takes, and it is worth understanding rather than reaching straight for a dependency.
const WORD_PATTERN = /[A-Z]{2,}(?=[A-Z][a-z]+|\b)|[A-Z]?[a-z]+|[A-Z]+|\d+/g
function toWords(input) {
return String(input).match(WORD_PATTERN) ?? []
}
function toCamelCase(input) {
return toWords(input)
.map((word, index) =>
index === 0
? word.toLowerCase()
: word[0].toUpperCase() + word.slice(1).toLowerCase()
)
.join('')
}
WORD_PATTERN is four alternatives, tried in order:
[A-Z]{2,}(?=[A-Z][a-z]+|\b)— an acronym. The lookahead is the clever part: it stops the match either at a proper capitalised word (soXMLHttpRequestsplits asXML+Http, notXMLH+ttp) or at a word boundary (soUSER_NAMEsplits asUSER+NAME).[A-Z]?[a-z]+— an ordinary word, with or without a leading capital.[A-Z]+— a run of capitals nothing else claimed.\d+— a run of digits, kept as its own word.
Because it matches words rather than replacing separators, every delimiter, repeated or leading or trailing, is simply never matched and therefore never appears in the output. You get that for free.
It handles the cases the one-liner could not:
toCamelCase('the-stealth-warrior') // 'theStealthWarrior'
toCamelCase('The_Stealth_Warrior') // 'theStealthWarrior'
toCamelCase('USER_NAME') // 'userName'
toCamelCase('XMLHttpRequest') // 'xmlHttpRequest'
toCamelCase('--leading--dashes--') // 'leadingDashes'
toCamelCase(' spaced out ') // 'spacedOut'
toCamelCase('hello world') // 'helloWorld'
toCamelCase('user2Name') // 'user2Name'
toCamelCase('foo_barBaz-QUX') // 'fooBarBazQux'
toCamelCase('alreadyCamelCase') // 'alreadyCamelCase'
toCamelCase('') // ''
That last pair matters in practice: running the function twice on the same string gives the same answer as running it once, so it is safe to apply to input that may already be camelCase.
Two limitations, stated honestly
Acronyms lose their capitalisation. getHTTPResponseCode becomes
getHttpResponseCode, not getHTTPResponseCode. This is deliberate — it is
also what Lodash does — because preserving acronym casing makes the
conversion non-reversible. If you need the original casing preserved, drop
the .toLowerCase() on the tail of each word, and accept that
USER_NAME will then give you userNAME.
It is ASCII-only. [A-Z] and [a-z] do not match accented or
non-Latin characters, so toCamelCase('背景 color') returns 'color' — the
non-Latin word is silently dropped. If you need Unicode, swap the character
classes for Unicode property escapes and add the u flag:
const WORD_PATTERN =
/\p{Lu}{2,}(?=\p{Lu}\p{Ll}+|\b)|\p{Lu}?\p{Ll}+|\p{Lu}+|\p{N}+/gu
Silently dropping characters is the kind of bug that surfaces months later in somebody else's locale, so it is worth deciding which of the two you want rather than inheriting the ASCII default by accident.
The other conversions
Once you have toWords, every other case convention is a one-line join:
function toPascalCase(input) {
return toWords(input)
.map((word) => word[0].toUpperCase() + word.slice(1).toLowerCase())
.join('')
}
function toKebabCase(input) {
return toWords(input).map((word) => word.toLowerCase()).join('-')
}
function toSnakeCase(input) {
return toWords(input).map((word) => word.toLowerCase()).join('_')
}
toPascalCase('the-stealth-warrior') // 'TheStealthWarrior'
toKebabCase('XMLHttpRequest') // 'xml-http-request'
toSnakeCase('userName') // 'user_name'
toKebabCase and toSnakeCase are the "reverse camelCase" people usually
mean — going from userName back to user-name or user_name. Because they
share the same word splitter, they round-trip with toCamelCase reliably
(subject to the acronym caveat above).
Do you need Lodash?
If Lodash is already in your bundle, use it:
import camelCase from 'lodash/camelCase'
camelCase('the-stealth-warrior') // 'theStealthWarrior'
camelCase('USER_NAME') // 'userName'
camelCase('XMLHttpRequest') // 'xmlHttpRequest'
_.camelCase is battle-tested
and Unicode-aware, so it keeps the non-Latin word the ASCII version above
drops: _.camelCase('背景 color') gives '背景Color'. Be aware that it also
runs _.deburr internally, which
strips diacritics — _.camelCase('café_au_lait') returns 'cafeAuLait', not
'caféAuLait'. That is usually what you want for a slug or an identifier, and
occasionally a surprise.
It also always lowercases the first word, so it cannot produce the
PascalCase-preserving behaviour of the CodeWars kata — use
_.upperFirst(_.camelCase(str)) if you want PascalCase.
If Lodash is not already there, importing it for this one function is not worth it. The word-splitting version above is fifteen lines and has no dependencies.
Doing it in the type system
If you are converting object keys in TypeScript — an API that returns
snake_case rows into a camelCase domain model, say — you want the types to
follow the runtime conversion. Template literal types can do it:
type CamelCase<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<CamelCase<Tail>>}`
: S extends `${infer Head}-${infer Tail}`
? `${Head}${Capitalize<CamelCase<Tail>>}`
: S
type A = CamelCase<'the-stealth-warrior'>
// 'theStealthWarrior'
type B = CamelCase<'user_first_name'>
// 'userFirstName'
infer Head is non-greedy, so it stops at the first delimiter, and the
recursion handles the rest of the string. Combine it with a key remapping to
convert a whole object type:
type CamelCaseKeys<T> = {
[K in keyof T as K extends string ? CamelCase<K> : K]: T[K]
}
type Row = { user_id: number; created_at: string }
type Model = CamelCaseKeys<Row>
// { userId: number; createdAt: string }
This is type-level only — you still need the runtime function to actually
transform the values. But it means the compiler catches a mismatch between
your API schema and your domain model rather than leaving it to a runtime
undefined.
Which one to use
- Input you control, simple delimiters — the one-liner. It is short enough to inline and there is nothing to maintain.
- Input you don't control — the word-splitting version. The acronym and repeated-delimiter cases will otherwise find you eventually.
- Lodash already in the bundle —
_.camelCase, and move on. - Converting object keys in TypeScript — the word-splitting version plus
the
CamelCaseKeystype, so the compiler stays in sync with the runtime.
Recommended Tools
These are affiliate links. Using them helps support this site at no extra cost to you.
Did you find this useful?
0 readers found this helpful
