logo
Published on

How to convert a string to camelCase in JavaScript

Authors
  • avatar
    Name
    Alberto Montalesi
    Twitter
This article was written with the assistance of AI.

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:

ConventionExample
camelCase (lower camel case)theStealthWarrior
PascalCase (upper camel case)TheStealthWarrior
snake_casethe_stealth_warrior
kebab-casethe-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 makes foo--bar work — 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 (so XMLHttpRequest splits as XML + Http, not XMLH + ttp) or at a word boundary (so USER_NAME splits as USER + 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 CamelCaseKeys type, so the compiler stays in sync with the runtime.

Did you find this useful?

0 readers found this helpful