A lot of frontend work starts with a JSON sample: an API response, a fixture, or an example payload from a backend teammate.
From there, the same three jobs come up almost every time:
- Write TypeScript interfaces that match the JSON.
- Write Zod schemas that validate the same shape at runtime.
- Spin up a quick local API that serves the JSON, because the real backend isn't ready yet.
Doing these by hand is slow, and worse, the types and the schemas slowly drift apart. So I built jsoncrafter, a small CLI that does all three.
npx jsoncrafter types user.json
It's on npm now as v0.1.0: jsoncrafter
JSON to TypeScript
Given user.json:
{
"id": 1,
"name": "Ada Lovelace",
"profile": { "bio": "Mathematician", "website": null },
"roles": ["admin"]
}
Run:
jsoncrafter types user.json --name User
And you get:
export interface User {
id: number;
name: string;
profile: Profile;
roles: string[];
}
export interface Profile {
bio: string;
website: null;
}
Nested objects become their own named types. website was only ever null in the sample, so jsoncrafter can't know its real type. Instead of guessing silently, it prints a warning so you can fix it by hand.
Multiple samples, merged properly
One sample rarely tells the full story. If you pass a top-level array, jsoncrafter treats every item as a sample and merges them:
[
{ "id": 1, "name": "Ada", "zip": null },
{ "id": "2", "name": "Alan", "zip": "M1", "nickname": "Prof" }
]
export type Users = User[];
export interface User {
id: string | number;
name: string;
zip: string | null;
nickname?: string;
}
- A key missing from some items becomes optional (
nickname?). - A key with different types becomes a union (
id: string | number). -
nullmixed with another type becomes nullable (zip: string | null).
This is the part that saves the most time with real API responses, where fields are often missing or inconsistent.
JSON to Zod
jsoncrafter zod user.json --name User
import { z } from "zod";
export const ProfileSchema = z.object({
bio: z.string(),
website: z.null(),
});
export type Profile = z.infer<typeof ProfileSchema>;
export const UserSchema = z.object({
id: z.number(),
name: z.string(),
profile: ProfileSchema,
roles: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
Every schema is exported along with its inferred type, and schemas are declared in dependency order, so you don't need z.lazy. Then validating an API response is one line:
const user: User = UserSchema.parse(await response.json());
Because TypeScript and Zod output come from the same inference model, they always agree on the same input. Want both at once?
jsoncrafter generate user.json --output src/generated
# -> src/generated/user.types.ts
# -> src/generated/user.schema.ts
A mock REST API from a folder of JSON
Point it at a folder of fixtures:
jsoncrafter mock ./fixtures
Mock server running at http://127.0.0.1:4000
GET http://127.0.0.1:4000/api/posts <- fixtures/api/posts.json
GET http://127.0.0.1:4000/users <- fixtures/users.json
File paths become routes (fixtures/api/posts.json → GET /api/posts), and CORS is enabled so your Vite or Next.js dev server can call it from another port.
There are two small features I use a lot for testing loading and error states:
jsoncrafter mock ./fixtures --delay 500 # slow every response by 500 ms
curl "http://127.0.0.1:4000/users?_status=500" # force a 500 error
Straight from a live API
You don't need to save the JSON to a file first. jsoncrafter reads from a URL or stdin:
jsoncrafter zod https://api.example.com/v1/me -H "Authorization: Bearer $TOKEN"
curl -s https://api.example.com/v1/users | jsoncrafter generate - --name User
Built to be safe
A tool that fetches URLs and writes files should be careful, so I made a few deliberate choices:
- No runtime dependencies. Nothing to audit except the tool itself.
-
Never overwrites files unless you pass
--force. Withgenerate, every target is checked first, so nothing is written if any file already exists. -
Private networks are blocked by default when fetching URLs, including localhost and the
169.254.169.254cloud metadata address. The check runs on the resolved IP of every connection, including redirects. Use--allow-private-networkfor your own local API. - Secrets aren't printed. Header values are never logged, and errors don't show query strings.
- Downloaded JSON is only parsed, never executed, and input values are never copied into the generated code.
-
The mock server binds to
127.0.0.1and only serves the fixtures it loaded at startup, so a request path can't be used to read other files.
Current limitations
It's v0.1.0, so a few things are on the roadmap rather than in the box:
- No literal or enum inference yet:
"status": "active"becomesstring. - No format detection: dates, emails and UUIDs are plain
string. - Discriminated unions aren't detected; objects in one array are merged into one type.
- The mock server is read-only and doesn't have
/users/:iditem routes yet.
Item routes for the mock server and optional enum inference are next.
Try it
It needs Node.js 22 or later:
npx jsoncrafter --help
If you try it on a real API response, I'd love to hear how the output looks, especially any case where the inferred types are wrong. Issues and pull requests are very welcome.
Top comments (0)