iKit
Guide · 9 min read ·

Rename Every JSON Key to camelCase: 3 Patterns (2026)

Renaming every key in a JSON object breaks on nesting, arrays and collisions. Three patterns — interceptor, codegen, manual — and when each one is correct.

Rename Every JSON Key to camelCase: 3 Patterns (2026)

Rename Every JSON Key to camelCase

A payload arrives with created_at, user_id and shipping_address, and your TypeScript interfaces all use camelCase. Renaming one key is trivial. Renaming every key — through arrays, three levels of nesting, and a metadata blob whose keys are user-supplied — is where the one-liner off Stack Overflow quietly drops data. Here are the three patterns that hold up, and the traps each one hides.

TL;DR

  • JSON.parse's reviver transforms values, not key names — it cannot rename.
  • Rebuild objects recursively into a fresh target; map arrays element by element.
  • Three patterns: HTTP interceptor, schema-driven codegen, one-off manual rewrite.
  • Check for collisions first: user_id and userId both become userId.
  • Convert once at a boundary, never scattered across components or models.

How to rename all keys in a JSON object to camelCase

The mechanics matter more than the naming rule, because the naming rule is the easy part. A conversion that handles user_name correctly still corrupts your payload if it flattens arrays or swallows null.

Why JSON.parse's reviver cannot rename keys

Almost everyone reaches for the reviver first. It looks like exactly the right hook: a callback that sees every key on the way in. It is not.

The reviver receives a key and a value, and — as MDN's JSON.parse() reference documents — the value it returns replaces the value, not the property name. Return undefined and the property is deleted outright.

// Reviver sees "user_name" but cannot
// change the property name.
JSON.parse('{"user_name":"ada"}',
  (key, value) => value
);
// -> { user_name: "ada" }

You can reach the containing object through the reviver's this binding and assign a new property there, but you are then fighting the traversal order: MDN specifies that revival is depth-first, deepest properties first, with the root value visited last under an empty-string key. Arrow functions get no this binding at all. It works in demos and breaks on real payloads.

One reviver feature is worth knowing for a different reason. The optional third context argument exposes context.source, the original JSON text of a primitive, which is the supported way to recover a large integer as a BigInt before Number has already rounded it. Renaming keys and preserving numeric precision are separate problems; do not try to solve both in one pass.

How to convert nested JSON keys recursively in JavaScript

The pattern that actually works is a rebuild. Never mutate — construct a new value of the same shape:

const toCamel = (s) =>
  s.replace(/_+([a-z0-9])/g,
    (_, c) => c.toUpperCase());

function renameKeys(input, fn) {
  if (Array.isArray(input)) {
    return input.map((v) => renameKeys(v, fn));
  }
  if (input === null ||
      typeof input !== "object") {
    return input;
  }
  if (input instanceof Date) return input;

  const out = {};
  for (const [k, v] of Object.entries(input)) {
    out[fn(k)] = renameKeys(v, fn);
  }
  return out;
}

Four guards, in order, and each one exists because omitting it corrupts something:

  • Array first. typeof [] === "object", so an array checked second becomes an object with "0", "1" keys.
  • null before typeof. typeof null === "object" is the oldest trap in JavaScript.
  • Primitives pass through. Strings, numbers and booleans are returned as-is, not iterated.
  • Class instances bail out. Date, Map, RegExp and friends have no enumerable own keys worth rewriting; rebuilding them produces {}.

If you are only handling freshly parsed JSON, the Date guard is unnecessary — JSON.parse never produces one. Keep it if the same helper also runs over in-memory objects.

What __proto__ does to a naive rebuild

out[fn(k)] = value is a plain assignment, and a plain assignment to __proto__ sets the object's prototype instead of creating a property. MDN calls out __proto__ as the single key where JSON text and the equivalent JavaScript object literal diverge. If any part of your payload has user-controlled key names — a metadata bag, a form-builder response, a tags object — build the target with Object.create(null), or write properties through Object.defineProperty, so a hostile key cannot reach the prototype chain.

Pattern 1: rename JSON keys in an HTTP interceptor

This is the retrofit. You have a client already talking to a snake_case API, and you want camelCase everywhere above the network layer without touching a hundred call sites.

Where the interceptor sits in the chain

Axios exposes two hooks, and the axios interceptors documentation describes them as middleware around the request. You convert outbound on the request side and inbound on the response side:

api.interceptors.response.use((response) => {
  response.data =
    renameKeys(response.data, toCamel);
  return response;
});

api.interceptors.request.use((config) => {
  if (config.data) {
    config.data =
      renameKeys(config.data, toSnake);
  }
  return config;
});

Execution order is worth reading the docs for: axios runs request interceptors last-in-first-out and response interceptors first-in-first-out. If a logging interceptor and a renaming interceptor both touch the payload, the one you registered second sees the request before the first one does. Register the rename closest to the wire.

The cost you pay on every response

An interceptor walks the entire payload on every single request. For a 40-key object that is free. For a 4 MB list endpoint it is a full deep clone plus a string transform per key, on the main thread, before your component ever renders.

It is also structurally blind. The interceptor has no idea that metadata holds user keys that must not be touched, or that one legacy endpoint returns PascalCase. Every exception becomes a path check inside a generic function, and that function grows for the life of the project.

When an interceptor is the right call

Use it when the API is consistent, the payloads are small, and you do not control the backend. That combination is common and the pattern is genuinely the cheapest fix available. Do not use it as a permanent architecture on a codebase where you own both ends.

Pattern 2: let the serializer own the mapping

The better answer is to stop transforming at runtime and declare the mapping where the schema already lives.

Jackson, Go struct tags, and declarative naming

Jackson has done this since long before anyone wrote a client-side interceptor. Annotate a class and every property is translated on the way out:

@JsonNaming(
  PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Order {
  private String customerId;  // customer_id
  private Instant placedAt;   // placed_at
}

The rules are more careful than the regex most people write. Per the Jackson SnakeCaseStrategy javadoc, contiguous capitals are treated as one acronym, so theWWW becomes the_www rather than the_w_w_w; a leading capital gets no underscore, so Results becomes results; and a capital already preceded by an underscore is not given a second one, so user_Name becomes user_name. Those three exceptions are exactly the cases a naive replace(/([A-Z])/g, "_$1") gets wrong.

Go takes the same idea further down: the field tag is the wire name, checked at compile time, visible in the struct definition. There is no ambiguity about what CustomerID serialises to, because you wrote it.

Generating typed models from a committed schema

If you have a JSON Schema or an OpenAPI document, generate the models. quicktype produces types and converters from JSON, JSON Schema, TypeScript or GraphQL queries across roughly twenty target languages, and its own README recommends the durable workflow: infer a schema from a sample, review and edit it, commit the schema to the repo, then generate model code as a build step.

The consequence is that iOS, Android and Node models are all generated from one artifact and therefore agree by construction. Nothing renames anything at runtime.

Why declarative mappings survive refactors

A rename rule expressed as a regex is invisible to your tooling. A rename rule expressed as an annotation, a struct tag or a generated model is a symbol: rename the field in your IDE and the mapping follows. When a field is deleted, the generator fails loudly instead of silently producing undefined.

Pattern Runs at Best when
Interceptor Every request You don't own the API
Serializer / codegen Build or serialize time You own both ends
Manual one-off Once, by hand Fixtures, seeds, migrations

Pattern 3: rename the keys once, by hand

Sometimes the JSON is not a stream. It is a fixture file, a seed dataset, or an export you have to load once and never think about again. Running a build pipeline for that is theatre.

An explicit key map beats a clever regex

For a one-off, write the mapping out. Twenty lines of { "old": "new" } is auditable in a code review; a regex with three lookarounds is not:

const MAP = {
  cust_no: "customerNumber",
  dob: "dateOfBirth",
  addr_1: "addressLine1",
};

const rename = (k) => MAP[k] ?? toCamel(k);

Explicit entries also fix the things automatic conversion cannot know. No case converter turns dob into dateOfBirth or decides that addr_1 should keep its digit. For interactive work on a single file, paste the key list into iKit's Case Converter to generate the right-hand column, then hand-correct the abbreviations.

How to handle key collisions when converting to camelCase

Case conversion is not injective. user_id, userId and user__id all converge on userId, and a blind rebuild silently keeps whichever came last in iteration order. Detect it instead:

function assertNoCollisions(obj, fn) {
  const seen = new Map();
  for (const k of Object.keys(obj)) {
    const next = fn(k);
    if (seen.has(next)) {
      throw new Error(
        `collision: ${seen.get(next)} + ${k}`
      );
    }
    seen.set(next, k);
  }
}

The related failure is round-tripping. Converting back is lossy whenever an underscore is not followed by a letter:

Original camelCase Back to snake
custom_label_0 customLabel0 custom_label0
_internal internal internal
user__id userId user_id

Treat the conversion as one-directional unless you have tested the round-trip on your actual key set.

Verifying the rewrite before you ship it

Two checks cost a minute and catch almost everything. Count the leaf values before and after — a correct rename changes names, never the number of values. Then diff the two files side by side in iKit's Diff Checker; every changed line should be a key, and any changed value is a bug you just caught. Pretty-print both sides first with the JSON Decoder so the diff compares structure rather than formatting. If the data started life as a spreadsheet export, the CSV ↔ JSON Converter will give you a clean starting object before any renaming happens.

All four run entirely in your browser — no upload, which matters when the fixture you are renaming contains real customer records.

Which pattern should you actually pick?

Work backwards from who owns the schema:

  • You own both ends: codegen from a committed schema. Everything else is a workaround.
  • You own only the client: an interceptor, scoped to one axios instance, not the global default.
  • It is a file, not a feed: an explicit key map, verified with a diff, done.
  • Keys are user-supplied: do not rename them at all. Treat that subtree as opaque data.

The mistake that costs the most is not picking the wrong pattern — it is picking two. A codebase with an interceptor and per-component snake_case fallbacks has no single answer to "what is this field called", and every new developer adds a third convention while trying to work it out.

References

Related on iKit

Related posts