DEV Community

Cover image for I built jsoncrafter: JSON to TypeScript types, Zod schemas and a mock REST API in one command
mhk sameera
mhk sameera

Posted on

I built jsoncrafter: JSON to TypeScript types, Zod schemas and a mock REST API in one command

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:

  1. Write TypeScript interfaces that match the JSON.
  2. Write Zod schemas that validate the same shape at runtime.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

Run:

jsoncrafter types user.json --name User
Enter fullscreen mode Exit fullscreen mode

And you get:

export interface User {
  id: number;
  name: string;
  profile: Profile;
  roles: string[];
}

export interface Profile {
  bio: string;
  website: null;
}
Enter fullscreen mode Exit fullscreen mode

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" }
]
Enter fullscreen mode Exit fullscreen mode
export type Users = User[];

export interface User {
  id: string | number;
  name: string;
  zip: string | null;
  nickname?: string;
}
Enter fullscreen mode Exit fullscreen mode
  • A key missing from some items becomes optional (nickname?).
  • A key with different types becomes a union (id: string | number).
  • null mixed 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
Enter fullscreen mode Exit fullscreen mode
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>;
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A mock REST API from a folder of JSON

Point it at a folder of fixtures:

jsoncrafter mock ./fixtures
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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. With generate, 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.254 cloud metadata address. The check runs on the resolved IP of every connection, including redirects. Use --allow-private-network for 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.1 and 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" becomes string.
  • 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/:id item 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
Enter fullscreen mode Exit fullscreen mode

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)