snake_caseに戻すとoauth2_tokenが壊れる ケース変換の往復
API のレスポンスは oauth2_token のような snake_case、フロントエンドの変数は oauth2Token のような camelCase。この差を埋めるために、JSON のキーを一括で変換する処理を挟むことがよくある。
ところが、lodash の camelCase で oauth2Token にしたキーを snakeCase で戻すと oauth_2_token になる。サーバーは知らないキーとして無視するので、送ったはずの値が保存されない。エラーはどこにも出ない。
この記事では、ケース変換が元に戻らない入力を lodash、camel-case(change-case 系)、ハシトシステムのケース変換ツール で測り、原因を整理する。最後に、戻らないキーを検出して止める変換関数を示す。
検証環境は Windows 11、Node.js v24.11.1、lodash 4.17.21、camel-case 4.1.2(no-case 3.0.4)、Python 3.11.9 である。
単語の区切りは文字列に残っていない
userId と user_id は、どちらも「user」と「id」の 2 語を表す。変換ライブラリは、まず文字列を単語の配列に分け、それを別の規則でつなぎ直す。
問題は、camelCase の文字列には「どこで区切ったか」が完全には残らないことである。oauth2Token は「oauth2 / token」とも「oauth / 2 / token」とも読める。ライブラリは決めた規則で一方を選ぶので、元の snake_case と違う選び方をすると、往復で別のキーになる。
数字を含むキーが戻らない
lodash 4.17.21 で、snake_case のキーを _.camelCase してから _.snakeCase で戻した結果である。
| 元のキー | _.camelCase |
_.snakeCase で戻す |
往復 |
|---|---|---|---|
user_id |
userId |
user_id |
一致 |
page_1 |
page1 |
page_1 |
一致 |
base64_encode |
base64Encode |
base_64_encode |
不一致 |
oauth2_token |
oauth2Token |
oauth_2_token |
不一致 |
ipv6_address |
ipv6Address |
ipv_6_address |
不一致 |
utf8_text |
utf8Text |
utf_8_text |
不一致 |
sha256_digest |
sha256Digest |
sha_256_digest |
不一致 |
x_2fa |
x2Fa |
x_2_fa |
不一致 |
a_b_c |
aBC |
a_bc |
不一致 |
lodash の snakeCase は、英字と数字の境目でも単語を区切る。_.words('base64Encode') は ['base', '64', 'Encode'] を返した。page_1 が戻るのは、元から英字と数字の間に区切りがあったからにすぎない。
a_b_c は別の理由で壊れる。1 文字の単語が続くと camelCase では aBC になり、BC が 1 語の略語に見えるためである。
略語は元の大文字に戻らない
逆向き、つまり camelCase から snake_case にして戻す場合は、略語の大文字が失われる。表の snake_case は lodash の snakeCase の結果である。camelCase に戻した結果は、lodash、camel-case、ハシトシステムのツールの 3 つとも同じだった。
| 元のキー | snake_case(lodash) | camelCase に戻す |
|---|---|---|
userID |
user_id |
userId |
HTMLParser |
html_parser |
htmlParser |
iOSVersion |
i_os_version |
iOsVersion |
IPv6Address |
i_pv_6_address |
iPv6Address |
OAuth2Token |
o_auth_2_token |
oAuth2Token |
userID が userId になるのは、snake_case が大文字小文字を持たない以上、避けられない。iOS や IPv6、OAuth のように大文字と小文字が混ざった固有名は、単語の途中で区切られる。IPv6Address は I / Pv / 6 / Address の 4 語として扱われた。
Python でよく見かける「大文字の前に _ を入れる」書き方は、略語を 1 文字ずつ分ける。
1 | |
こちらは数字を区切らないので base64Encode は base64_encode に戻るが、略語には弱い。どのライブラリにも、得意な入力と苦手な入力がある。
日本語キーが空文字列になる
ASCII 以外の文字の扱いは、ライブラリごとに大きく違った。
| 入力 | lodash camelCase |
camel-case 4.1.2 | ハシトシステムのツール |
|---|---|---|---|
parseÉcole |
parseEcole |
parseCole |
parseécole |
straße |
strasse |
straE |
straße |
ユーザーID |
ユーザーid |
id |
ユーザーid |
ユーザー名 |
ユーザー名 |
空文字列 | ユーザー名 |
camel-case 4.1.2 が依存する no-case 3.0.4 は、既定で /[^A-Z0-9]+/gi に当たる文字を区切りとして取り除く。ソースのコメントにも「単語以外の文字をすべて削除する」とある。日本語もアクセント付きの文字も、この正規表現では単語の文字として扱われない。
そのため、日本語のキーを持つオブジェクトを変換すると、キーが空文字列になってぶつかる。
1 | |
値は 2 つあったのに、結果のキーは 1 つで、1 は消えている。
lodash は日本語を残すが、É を E に、ß を ss に置き換える。lodash 4.17.21 のソース(_createCompounder.js)では、camelCase や snakeCase が単語に分ける前に deburr(ラテン文字の発音区別符号を外す処理)を通している。_.deburr('Æsir') は Aesir になった。フランス語やドイツ語のキーは、元の綴りに戻せない。
ハシトシステムのツールは、単語の境目を [a-z] と [A-Z] だけで判定している。そのため parseÉcole の É の前では区切らず、1 語として小文字にする。文字は消えないが、区切りも入らない。
同じキーに集まる
区切り文字の違いだけの 2 つのキーは、同じ camelCase になる。
1 | |
Object.fromEntries は後のキーで上書きするので、最後の値だけが残る。先頭や末尾の _ も消えるため、__proto__ は proto に、_private は private になる。_id と id を両方持つデータでは、どちらかが消える。
大文字にすると文字数が変わる
CONSTANT_CASE にするときの toUpperCase() も、元に戻らない変換を含む。Unicode の SpecialCasing では、1 文字が複数の文字に対応する場合がある。
| 入力 | toUpperCase() |
さらに toLowerCase() |
|---|---|---|
straße |
STRASSE |
strasse |
file(合字 U+FB01) |
FILE |
file |
İstanbul |
İSTANBUL |
i̇stanbul(9 文字) |
İ(U+0130)を小文字にすると、i と結合用の点(U+0307)の 2 文字になる。8 文字の İstanbul が、小文字にすると 9 文字になった。Python 3.11.9 の str.upper() と str.lower() も同じ結果を返した。
トルコ語の規則で変換したいときは toLocaleUpperCase('tr') を使う。'i'.toLocaleUpperCase('tr') は İ、'I'.toLocaleLowerCase('tr') は ı になる。逆に、識別子の変換で実行環境のロケールに影響されたくないなら、toLocaleUpperCase() を引数なしで使わない。
実際に試す
前提: Node.js(確認は v24.11.1)。外部パッケージは不要である。
方針は 2 つある。1 つ目は、変換できる形を正規表現で先に限定し、それ以外のキーはエラーにすること。2 つ目は、変換したキーを逆変換して元に戻るか確かめ、戻らないキーは対応表(overrides)に書かせること。
1 | |
実行結果は次のとおりである。
1 | |
この関数は数字で区切らないので、lodash で壊れた base64_encode や oauth2_token はそのまま戻る。代わりに、lodash では戻った page_1 が、この関数では戻らない。_ の後ろが数字で始まる単語は、camelCase にすると区切りが消えるからである。どの規則を選んでも戻らないキーは残るので、黙って変換せず、検出して対応表に書かせる。
userName のような camelCase のキーや日本語のキーが snake_case 側に紛れ込んだときも、エラーで止まる。空文字列や上書きで値が消えることはない。
lodash の表を手元で再現するなら、空のディレクトリで npm i lodash@4.17.21 を実行してから次を打つ。
1 | |
ハシトシステムのケース変換では
文字列ケース変換 は、入力を camelCase、PascalCase、snake_case、kebab-case、CONSTANT_CASE、dot.case、Title Case などに一括で変換し、1 行ずつコピーできる。2026-11-10 に本番の HTML から単語分割の関数を取り出して、上の表の入力を通した。
- 区切りの規則は lodash に近い。
_、-、.、空白で区切り、小文字と大文字の境目、連続する大文字の末尾、英字と数字の境目でも区切る。そのためbase64Encodeの snake_case はbase_64_encodeになる。API のキー名を作るときは、数字の前に_が入っていないか確かめる。 HTMLParserはHTML / Parserに分かれ、getHTTPResponseCodeはget_http_response_codeになる。- ASCII 以外の文字は消さない。
ユーザー名はそのまま残り、snake_case のstraßeもstrasseにはならない(CONSTANT_CASE はtoUpperCase()を使うのでSTRASSEになる)。ただしparseÉcoleのように ASCII 以外の大文字の前では区切らない。
入力した文字列はブラウザ内で処理され、サーバーには送られない。JSON の整形は JSON 整形、YAML との相互変換は YAML/JSON 変換 で試せる。YAML でキーや値の型が変わる話は YAML の NO が false になる記事 に、大文字小文字の変換で ASCII の外から文字が入る話は parseInt の記事 に書いた。
まとめ
- camelCase には単語の区切りが完全には残らない。
oauth2Tokenを lodash のsnakeCaseで戻すとoauth_2_tokenになる。 - 略語の大文字は snake_case を経由すると戻らない。
userIDはuserId、IPv6AddressはiPv6Addressになる。 - camel-case 4.1.2(no-case 3.0.4)は既定で ASCII の英数字以外を取り除く。日本語キーは空文字列になり、ぶつかって値が消える。
- lodash は
deburrを通すので、ÉはE、ßはssになる。 - 区切りだけが違うキーや、先頭に
_があるキーは同じキーに集まり、後の値で上書きされる。 toUpperCase()は文字数を変えることがある。識別子の変換はロケールを指定しないtoUpperCase()で行い、ASCII に限定するのが安全である。- 変換したキーは逆変換で元に戻るか確かめ、戻らないキーは対応表に書く。
結果は 2026-11-10 時点の上記の環境とバージョンで得たものである。ライブラリの版によって区切りの規則が変わる可能性があるので、手元でも上のスクリプトで確かめてほしい。
参考
- lodash ドキュメント —
_.camelCase、_.snakeCase、_.words、_.deburr(あわせて 4.17.21 のソース_createCompounder.js) - change-case リポジトリ(no-case 3.0.4 の
dist/index.js、既定の分割と除去の正規表現) - MDN —
String.prototype.toUpperCase()、String.prototype.toLocaleUpperCase() - Unicode Character Database — SpecialCasing.txt
- Python ドキュメント —
str.upper()、str.lower()、str.casefold()
このバージョンはその範囲に入るのか
依存の話でいちばん間違えやすいのは ^1.2.3 や ~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。