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
2
3
4
5
import re
snake = lambda s: re.sub(r'(?<!^)(?=[A-Z])', '_', s).lower()
snake('userID') # 'user_i_d'
snake('HTMLParser') # 'h_t_m_l_parser'
snake('base64Encode') # 'base64_encode'

こちらは数字を区切らないので 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
3
4
const { camelCase } = require('camel-case'); // 4.1.2
const src = { 'ユーザー名': 1, 'メール': 2 };
Object.fromEntries(Object.entries(src).map(([k, v]) => [camelCase(k), v]));
// { '': 2 }

値は 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
2
3
4
5
6
const _ = require('lodash'); // 4.17.21
const camelizeKeys = (o) =>
Object.fromEntries(Object.entries(o).map(([k, v]) => [_.camelCase(k), v]));

camelizeKeys({ user_name: 'a', userName: 'b', 'user-name': 'c' });
// { userName: 'c' }

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// case-keys.mjs(Node.js v24.11.1 で確認・外部パッケージ不要)
// 使い方: node case-keys.mjs
const SNAKE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
const CAMEL = /^[a-z][A-Za-z0-9]*$/;

export function snakeToCamel(key) {
if (!SNAKE.test(key)) throw new SyntaxError(`snake_case ではない: ${JSON.stringify(key)}`);
return key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
}

export function camelToSnake(key) {
if (!CAMEL.test(key)) throw new SyntaxError(`camelCase ではない: ${JSON.stringify(key)}`);
return key.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
}

// 変換して戻したら元に戻るキーだけ通す。戻らないキーは overrides で明示する
export function camelizeKeys(value, overrides = {}) {
if (Array.isArray(value)) return value.map((v) => camelizeKeys(v, overrides));
if (value === null || typeof value !== 'object') return value;
const out = {};
for (const [k, v] of Object.entries(value)) {
let nk = Object.hasOwn(overrides, k) ? overrides[k] : snakeToCamel(k);
if (!Object.hasOwn(overrides, k) && camelToSnake(nk) !== k) {
throw new Error(`戻すと別のキーになる: ${k} -> ${nk} -> ${camelToSnake(nk)}`);
}
if (Object.hasOwn(out, nk)) throw new Error(`キーが衝突した: ${nk}`);
out[nk] = camelizeKeys(v, overrides);
}
return out;
}

export function snakifyKeys(value, overrides = {}) {
const reverse = Object.fromEntries(Object.entries(overrides).map(([s, c]) => [c, s]));
if (Array.isArray(value)) return value.map((v) => snakifyKeys(v, overrides));
if (value === null || typeof value !== 'object') return value;
const out = {};
for (const [k, v] of Object.entries(value)) {
const nk = Object.hasOwn(reverse, k) ? reverse[k] : camelToSnake(k);
if (Object.hasOwn(out, nk)) throw new Error(`キーが衝突した: ${nk}`);
out[nk] = snakifyKeys(v, overrides);
}
return out;
}

const attempt = (f) => {
try { return JSON.stringify(f()); } catch (e) { return `${e.name}: ${e.message}`; }
};

import { fileURLToPath } from 'node:url';
if (process.argv[1] === fileURLToPath(import.meta.url)) {
for (const k of ['user_id', 'base64_encode', 'oauth2_token', 'a_b_c', 'page_1', 'x_2fa', 'user__name', '_private', 'ユーザー名', 'userName']) {
console.log(`${k} -> ${attempt(() => camelizeKeys({ [k]: 1 }))}`);
}
const body = { oauth2_token: 't', ipv6_address: '::1', items: [{ sha256_digest: 'ab', x_2fa: true }] };
console.log('\noverrides なし:', attempt(() => camelizeKeys(body)));
const ov = { x_2fa: 'x2Fa' };
const camel = camelizeKeys(body, ov);
console.log('overrides あり:', JSON.stringify(camel));
console.log('元に戻す :', JSON.stringify(snakifyKeys(camel, ov)));
console.log('一致 :', JSON.stringify(snakifyKeys(camel, ov)) === JSON.stringify(body));
console.log('衝突 :', attempt(() => camelizeKeys({ user_name: 1, userName: 2 })));
}

実行結果は次のとおりである。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ node case-keys.mjs
user_id -> {"userId":1}
base64_encode -> {"base64Encode":1}
oauth2_token -> {"oauth2Token":1}
a_b_c -> {"aBC":1}
page_1 -> Error: 戻すと別のキーになる: page_1 -> page1 -> page1
x_2fa -> Error: 戻すと別のキーになる: x_2fa -> x2fa -> x2fa
user__name -> SyntaxError: snake_case ではない: "user__name"
_private -> SyntaxError: snake_case ではない: "_private"
ユーザー名 -> SyntaxError: snake_case ではない: "ユーザー名"
userName -> SyntaxError: snake_case ではない: "userName"

overrides なし: Error: 戻すと別のキーになる: x_2fa -> x2fa -> x2fa
overrides あり: {"oauth2Token":"t","ipv6Address":"::1","items":[{"sha256Digest":"ab","x2Fa":true}]}
元に戻す : {"oauth2_token":"t","ipv6_address":"::1","items":[{"sha256_digest":"ab","x_2fa":true}]}
一致 : true
衝突 : SyntaxError: snake_case ではない: "userName"

この関数は数字で区切らないので、lodash で壊れた base64_encode や oauth2_token はそのまま戻る。代わりに、lodash では戻った page_1 が、この関数では戻らない。_ の後ろが数字で始まる単語は、camelCase にすると区切りが消えるからである。どの規則を選んでも戻らないキーは残るので、黙って変換せず、検出して対応表に書かせる。

userName のような camelCase のキーや日本語のキーが snake_case 側に紛れ込んだときも、エラーで止まる。空文字列や上書きで値が消えることはない。

lodash の表を手元で再現するなら、空のディレクトリで npm i lodash@4.17.21 を実行してから次を打つ。

1
2
3
4
node -e "const _=require('lodash');for(const k of ['base64_encode','oauth2_token','a_b_c'])console.log(k,_.snakeCase(_.camelCase(k)))"
# base64_encode base_64_encode
# oauth2_token oauth_2_token
# a_b_c a_bc

ハシトシステムのケース変換では

文字列ケース変換 は、入力を 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()

snake_caseに戻すとoauth2_tokenが壊れる ケース変換の往復
https://blog.hashito.biz/2026/11/10/camelcase-snake-case-roundtrip-lodash-oauth2-base64-japanese-keys/
著者
hashito
作成日
2026年11月10日
著作権

このバージョンはその範囲に入るのか

依存の話でいちばん間違えやすいのは ^1.2.3 や ~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。