transform-obj library for manipulate data object
· Rizkyy. · 8 min read
On this page
Keep Your Frontend Types Clean: Transform API Responses at the Boundary
Most frontends that consume a backend eventually leak the backend's naming conventions into places they don't belong. It's a small thing that compounds: a snake_case field here, a manual alias there, and a year later half your codebase reads like a REST payload and the other half reads like idiomatic TypeScript.
This post is about drawing a clear boundary — transforming responses once, at the edge of your app — and, just as importantly, about the tradeoffs and the alternatives, so you can decide whether this pattern is worth adopting on your project.
The problem
A typical response from a Django, Rails, or Laravel backend looks like this:
{
"data": {
"name": "Muhamad Rizky",
"is_active": true,
"is_admin": true,
"is_available_for_now": false
}
}Backend frameworks tend to favor snake_case. The JavaScript and TypeScript ecosystem overwhelmingly favors:
camelCasefor variables, properties, and functionsPascalCasefor types, classes, and componentsUPPER_CASEfor constants
The path of least resistance is to copy the payload shape straight into TypeScript:
type User = {
name: string;
is_active: boolean;
is_admin: boolean;
is_available_for_now: boolean;
};It compiles. But now snake_case propagates through your components:
if (user.is_available_for_now) {
// ...
}And it sits next to libraries — React Hook Form, Zod, TanStack Query, Zustand — that all assume you're writing JavaScript, not deserializing a backend payload. The result is a codebase where developers have to remember, per object, which fields have been transformed and which haven't. That ambiguity is the real cost, not the underscores themselves.
The boundary pattern
The core idea: normalize the naming exactly once, at the point where external data enters your application. Everything downstream of that boundary is idiomatic TypeScript with no knowledge of how the wire format looked.
Backend (snake_case)
│
▼
transform at the boundary
│
▼
Frontend (camelCase everywhere)The backend keeps the conventions that make sense for the backend. The frontend keeps the conventions that make sense for TypeScript. Neither has to compromise, and no component ever sees both.
The catch — and the part most blog posts skip — is that a boundary has two sides. Data flows out as well as in. We'll cover both.
Make it type-safe, or don't bother
This is the part that determines whether the whole approach is worth anything.
Transforming keys at runtime does nothing to your types by itself. If transformToCamelCase returns any (or returns the same type it received), then a Product interface written in camelCase is a lie the compiler can't verify — you've traded honest snake_case types for dishonest camelCase ones.
For the pattern to actually pay off, the transform function's return type must map the input's snake_case string-literal keys to camelCase, recursively. Concretely, its signature should be equivalent to:
import type { CamelCasedPropertiesDeep } from "type-fest";
declare function transformToCamelCase<T>(
input: T
): CamelCasedPropertiesDeep<T>;With that in place, you define your types once, in wire format, and let the type system derive the camelCase shape:
// The shape as it arrives on the wire
interface ProductDTO {
id: number;
image_url: string;
product_name: string;
is_sold_out: boolean;
created_at: string;
}
// Derived automatically — never written by hand
type Product = CamelCasedPropertiesDeep<ProductDTO>;
// { id: number; imageUrl: string; productName: string;
// isSoldOut: boolean; createdAt: string }If you'd rather not couple your types to a snake_case DTO at all, the alternative is to validate the transformed data with a schema (see the Zod option below). Either way, the rule is the same: the camelCase type must be derived from or validated against reality, not asserted into existence with a cast.
If your transform library doesn't provide these mapped types, that's the first thing to fix — everything else in this pattern rests on it.
Read path: transform once in the query layer
TanStack Query's select is a natural place to transform, because it runs against the cached data and the result is what every consumer of the hook receives:
import { useQuery } from "@tanstack/react-query";
import { transformToCamelCase } from "transform-obj";
export function useRandomProducts() {
return useQuery({
queryKey: ["random-products"],
queryFn: () => productService.getRandomProducts(),
select: transformToCamelCase,
});
}Two things worth knowing about select:
- The query cache stores the raw response;
selecttransforms a derived view. That's usually fine, but if you inspect the cache directly (devtools,getQueryData), you'll seesnake_case. selectis memoized per observer and re-runs when the underlying data changes, so the cost is bounded — but it is client-side work on every data update. For large payloads, see the tradeoffs section.
Downstream, components are clean and fully typed:
function ProductList() {
const { data } = useRandomProducts();
return (
<>
{data?.data.map((product) => (
<article key={product.id}>
<img src={product.imageUrl} alt={product.productName} />
<h2>{product.productName}</h2>
<p>{product.isSoldOut ? "Sold Out" : "Available"}</p>
</article>
))}
</>
);
}Write path: transform back on the way out
This is the half that's easy to forget. If your reads are camelCase but your backend expects snake_case, every mutation body has to be transformed in the opposite direction:
import { transformToSnakeCase } from "transform-obj";
export function useUpdateProfile() {
return useMutation({
mutationFn: (profile: Profile) =>
userService.updateProfile(transformToSnakeCase(profile)),
});
}The symmetry is the whole point of a boundary: snake_case exists on the wire, camelCase exists in your app, and the two transform functions are the only places that know both. If you only implement the read side, you've built half a bridge — forms will read cleanly and then fail on submit.
Forms themselves get simpler once both sides are in place, because your validation schema, your form state, and your API model all share one shape:
const ProfileSchema = z.object({
name: z.string(),
isActive: z.boolean(),
isOnline: z.boolean(),
canUpdateProfile: z.boolean(),
});
const { data } = useProfile();
const form = useForm({ resolver: zodResolver(ProfileSchema) });
useEffect(() => {
if (data) form.reset(data); // no field-by-field aliasing
}, [data]);Edge cases your transformer must handle
A recursive key transformer is deceptively simple to write and easy to get subtly wrong. Before trusting one in production, confirm it handles:
- Non-plain objects.
Date,Map,Set,RegExp,File, and class instances must be passed through untouched. A naive recursion turnsnew Date()into{}. - Prototype pollution. Keys like
__proto__,constructor, andprototypemust never be written onto the output object. This is a real security vector for anything that rebuilds objects from untrusted keys. - Already-camelCase and mixed keys. Transforming an already-transformed object should be idempotent.
- Numeric and edge segments. Decide deliberately what
address_line_1,_internal, andsome__doublemap to — and make it consistent in both directions, or your write path won't round-trip. nullvsundefinedvs arrays of primitives. These should short-circuit, not recurse.
The reason this matters: the moment the transform isn't a clean bijection, your read and write paths disagree, and you get bugs that only appear on submit.
Alternatives worth considering first
The boundary-transform pattern is a good fit for some projects and the wrong tool for others. Before adopting it, weigh these:
1. Generate the client from an OpenAPI/Swagger spec.
Tools like openapi-typescript or orval derive your types and often your fetch layer directly from the API contract. The decisive advantage: your types can't drift from the real API, because they're generated from it. Many generators can emit camelCase directly, giving you clean naming and guaranteed accuracy with zero runtime transform. If your backend publishes a spec, this is usually the strongest option.
2. Do it in Zod (.transform() / keyed schemas).
A schema can validate the wire shape and rename keys in one pass, with the output type inferred automatically:
const Product = z
.object({
id: z.number(),
image_url: z.string(),
is_sold_out: z.boolean(),
})
.transform(({ image_url, is_sold_out, ...rest }) => ({
...rest,
imageUrl: image_url,
isSoldOut: is_sold_out,
}));This is more verbose per model, but you get runtime validation at the boundary for free — which a blind key transform does not provide. If you already run Zod at your API edge, folding the rename into it may be all you need.
3. Configure the backend serializer.
If you own the backend, many frameworks can emit camelCase at the serialization layer (or via a middleware). This eliminates the problem entirely, at zero frontend cost. It's the cheapest fix when it's available to you.
A generic recursive transformer earns its place when you don't have a spec, don't control the backend, and want one small dependency instead of per-model schemas.
The library: transform-obj
For the "no spec, no backend control" case, I built transform-obj — a small utility that recursively converts object keys between snake_case and camelCase, including nested objects and arrays, in both directions. It ships the mapped types described above so the compiler tracks the transformation, and it's about 1.5 KB minified — small enough that bundle size isn't a reason to hesitate.
Bundle size is genuinely the least important property, though. What matters is that it's type-correct, round-trips cleanly, and handles the edge cases above. Judge it (and any competitor) on those first.
You can experiment with it in this CodeSandbox playground.
When not to reach for this
Be honest about the cost. Skip the boundary transform when:
- You already have codegen from an API spec — it solves this better.
- The app is small (a handful of endpoints), where per-call clarity beats a global abstraction.
- Payloads are large and on a hot path, where transforming thousands of keys per response is measurable. Profile before assuming it's negligible.
- You need runtime validation anyway — in that case, do the rename inside your validation layer so you're not walking the object twice.
Takeaways
The friction between backend and frontend naming is real, and letting snake_case bleed through your app is a slow, compounding tax on readability. The fix is a clean boundary:
- Transform once, at the edge — and transform in both directions, not just reads.
- Make it type-safe: the camelCase type must be derived or validated, never asserted.
- Handle the edge cases (
Date, class instances, prototype pollution, round-trip consistency) or expect subtle mutation bugs. - Reach for codegen or backend config first if either is available; a recursive transformer is the right tool specifically when they aren't.
Get the boundary right and everything downstream — components, forms, Zod schemas, stores — collapses to a single, consistent shape. That consistency, not the naming style itself, is the actual win.
Happy building. 🚀