npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

frappe-zod-client

v0.2.1

Published

A Frappe / ERPNext REST client typed by the schemas frappe-zod generates from your installation

Readme

frappe-zod-client

A Frappe / ERPNext REST client typed by your installation. frappe-zod generates Zod schemas and types from a snapshot of your site; this package turns them into a client that knows every doctype, every field and what each doctype allows.

// src/frappe.ts: your app's client, from the generated module and your app's API user
import { createFrappeClient } from "frappe-zod-client";
import * as generated from "../frappe-zod-generated/index.js";

export const frappe = createFrappeClient(generated, {
  url: process.env.FRAPPE_URL,
  apiKey: process.env.FRAPPE_API_KEY,
  apiSecret: process.env.FRAPPE_API_SECRET,
});
// anywhere else
import { frappe } from "./frappe";

const invoice = await frappe.docs.salesInvoice.get("ACC-SINV-2026-00001");
invoice.items[0]?.item_code;                     // typed, child rows included

const open = await frappe.docs.todo.list({
  fields: ["name", "description", "owner as assignee"],
  filters: [["status", "=", "Open"]],
});
open[0]?.assignee;                                // string

await frappe.docs.salesInvoice.submit(invoice.name);
await frappe.docs.todo.submit("x");               // compile error: ToDo isn't submittable

There is no client without the generated code: you create it from that code, so nothing can be used before it's loaded. Creating it never fails; the client checks its options on its first request.

frappe-zod-client bundles frappe-zod, which reads your site's schema and generates from it: one package to install, one CLI, and frappe-zod's types re-exported. This README covers all of it.

Your Frappe / ERPNext site
   │
   │  pull (GET only); check tells you when the site has drifted from the snapshot
   ▼
frappe-schema/prod.snapshot.json    committed, reviewed like code
   │
   │  generate (offline)
   ▼
frappe-zod-generated/               .js + .d.ts, gitignored, rebuilt on every install
   │
   │  createFrappeClient(generated, …)
   ▼
Your code                           frappe.docs.salesInvoice.get(…), DocOf<"Sales Invoice">

First steps

1. Install

pnpm add frappe-zod-client zod

zod is a peer dependency: the generated schemas import zod/v4 from your project, so they are the same zod as yours (zod 3.25+ or 4). Node 22+.

2. Two API users

frappe-zod-client talks to your site twice, with different rights:

| For | Needs | Config | |---|---|---| | reading the schema (pull, check) | read access to DocType, Custom Field, Property Setter, Installed Applications | schema.refs in the config | | your app's calls | whatever your app reads and writes | createFrappeClient in your app |

In your site, open each user, Settings → API Access → Generate Keys, and note key and secret. One user can serve both, but a read-only schema user is safer in CI.

3. Run init

npx frappe-zod-client init
Name of this installation (ref) [prod]:
Site URL, or the environment variable that holds it [FRAPPE_PROD_URL]:
Environment variable with the API key [FRAPPE_PROD_API_KEY]:
Environment variable with the API secret [FRAPPE_PROD_API_SECRET]:
Extra headers for a proxy in front of the site, as Header=ENV_VAR, comma-separated, "cloudflare" for Cloudflare Access, or none [none]:
Add another installation? (y/n) [n]:
Directory for the generated code [frappe-zod-generated]:
Apps to leave out, comma-separated (e.g. hrms), or none [none]:
Add scripts to package.json? postinstall generates (pulling the first snapshot), predev runs check (y/n) [y]:
Command that provides the credentials (e.g. "varlock run --"), or none [none]:
Add frappe-zod-generated/ to .gitignore? (y/n) [y]:

Press Enter to accept the suggestion in brackets. The headers question is for a site behind Cloudflare Access or another proxy that wants its own credentials (Sites behind a proxy). When zod isn't in package.json, init offers to add it, and it adds predev only when there is a dev script. It writes frappe-zod.config.ts (Configuration) and these scripts:

"scripts": {
  "postinstall": "frappe-zod-client generate --pull-if-missing",
  "predev": "frappe-zod-client check --warn"
}

npx frappe-zod-client init --yes takes every suggestion. Its last step shows the module that creates your app's client (step 6).

4. Provide the credentials

The config names environment variables, never values. Set them however your project handles secrets (varlock, dotenv, your platform's secret store): FRAPPE_PROD_* where you pull and check, and whatever your app passes to createFrappeClient where it runs.

If they only exist inside a runner (varlock run --, doppler run --), give that prefix in init. The scripts then run inside it, and the install falls back to the committed snapshot where the runner can't provide them, for example in CI without the secret store.

5. Install, commit

pnpm install   # postinstall pulls the first snapshot and generates
git add frappe-zod.config.ts frappe-schema package.json .gitignore

Without the scripts: npx frappe-zod-client pull && npx frappe-zod-client generate. From then on every install generates offline from the committed snapshot. To take in schema changes, run pull and review the snapshot diff like any other change.

6. Create the client

// src/frappe.ts (the generated path relative to this file)
import { createFrappeClient } from "frappe-zod-client";
import * as generated from "../frappe-zod-generated/index.js";

export const frappe = createFrappeClient(generated, {
  url: process.env.FRAPPE_URL,
  apiKey: process.env.FRAPPE_API_KEY,
  apiSecret: process.env.FRAPPE_API_SECRET,
});
// Anywhere else:
import { frappe } from "./frappe";

const todo = await frappe.docs.todo.create({ description: "Call the supplier" });

Pass the generated module itself, not a path: TypeScript and bundlers follow the import, and it brings the generated types into your program, which types frappe by your installation everywhere. Write /index.js out: ESM can't import a directory. This works in ESM and CommonJS, with every moduleResolution including node10.

| Option | Default | | |---|---|---| | url | | the site | | apiKey, apiSecret | | your app's API user; leave both out for a site that needs no token | | headers | | extra headers, e.g. Cloudflare Access | | timeoutMs | 30000 | per attempt | | retries | 2 | for rate limits, and for reads a gateway couldn't answer | | userAgent | "frappe-zod-client" | | | fetch | global fetch | e.g. a fake one in tests |

The values may be undefined, as process.env gives them. The client checks them on its first request, never when it's created, so tests, builds and type generation import your client module without credentials; a missing url, or a key without its secret, fails that request with a ConfigError. Nothing is global: create a second client for a second site with the same schema, or one with a fake fetch in a test.

Configuration

One file in the project root, frappe-zod.config.ts (or .js, .mjs, .cjs, .mts, .cts), with one section:

import { defineConfig } from "frappe-zod-client";

export default defineConfig({
  // How the CLI reads your installation's schema (pull, check) and what it generates.
  schema: {
    refs: { prod: { urlEnv: "FRAPPE_PROD_URL", apiKeyEnv: "FRAPPE_PROD_API_KEY", apiSecretEnv: "FRAPPE_PROD_API_SECRET" } },
    defaultRef: "prod",
    outDir: "frappe-zod-generated",
    exclude: { apps: ["hrms"] },
  },
});

defineConfig from frappe-zod-client makes the generated types extend this package's registry, so they type createFrappeClient's client. Your app's own credentials aren't part of the config: the app passes them when it creates its client.

| Option | Default | | |---|---|---| | refs | | installations by name: url or urlEnv, apiKeyEnv, apiSecretEnv, headers (extra request headers) | | defaultRef | "prod" | the ref commands use without --ref | | outDir | "frappe-zod-generated" | where generate writes; gitignore it | | snapshotDir | "frappe-schema" | where snapshots live; commit it | | include | "*" | doctype names to generate, or all | | exclude | | { apps, modules, doctypes } to leave out, e.g. { apps: ["hrms"] } | | alwaysInclude | | doctypes kept even when exclude matches, e.g. ones a library needs | | input | | { optional, required, siteDefaults }: what create requires, see Input | | isPermlevelRestricted | false | the API user lacks some permission levels: fields above permlevel 0 read as optional, see Field permissions | | transitions | | per doctype and field, the other shapes a field takes while the site moves between versions, see Transition overrides | | extends | | presets (module names or config objects with a schema section) merged underneath | | format | "auto" | esm or cjs output; auto follows your package.json type | | registryModule | "frappe-zod-client" with this defineConfig | module whose DocTypeRegistry the types augment; false skips it | | zodModule | "zod/v4" | where generated code imports zod from | | userAgent, concurrency | "frappe-zod", 4 | for pull and check |

Child tables of an included doctype are always generated, even from an excluded app.

Input

create takes InputOf<N>, which requires what Frappe's insert checks and doesn't fill itself (what Frappe fills in). What a doctype's code fills in validate, or what your site has defaults for, the snapshot can't know. schema.input says it:

schema: {
  input: {
    // Filled by the server: optional in InputOf.
    optional: { "Sales Invoice": ["debit_to"], "Sales Invoice Item": ["income_account", "uom"] },
    // Always sent, even where Frappe would fill it in: here the server would guess a quantity of 1.
    required: { "Sales Order Item": ["qty"] },
    // Fieldnames with a site or user default (Global Defaults, user defaults): required Links of that name become optional.
    siteDefaults: ["company", "currency"],
  },
},

required and optional have the final say over the rules; required wins where both name a field. Through extends, the lists are united per doctype, and a field your config lists on one side leaves the preset's other side. generate warns about doctypes and fields it doesn't find.

For ERPNext v15, frappe-zod ships what the standard doctypes' code fills, as a preset:

export default defineConfig({
  schema: {
    extends: "frappe-zod/presets/erpnext-v15",
    input: { siteDefaults: ["company", "currency"] },
    // …
  },
});

The config resolves extends from its own file, so frappe-zod has to be resolvable from your project: with pnpm, or any install that doesn't hoist dependencies, add frappe-zod itself (the version frappe-zod-client depends on). Code that builds its config can import the preset instead: import { preset } from "frappe-zod/presets/erpnext-v15", and put it in extends.

The preset makes optional the required fields that frappe's and ERPNext's own controllers fill on insert, scanned from their source (generatedFrom in the preset says which versions), and keeps required what the server would only guess: an item row's qty of 1, a Shipment's pickup times. It knows neither your custom fields nor your site's defaults; add those yourself.

Correcting the input rules

What you know about your site beats the rules and the preset. Name the field in your config, on the side it belongs:

export default defineConfig({
  schema: {
    extends: "frappe-zod/presets/erpnext-v15",
    input: {
      // Your code fills it (a hook, a server script, a custom app): optional in InputOf.
      optional: {
        "Sales Invoice": ["custom_region"],
        // A child table's field goes under the child doctype, for every parent using that table:
        // here a server script fills each row's custom_cost_code before save.
        "Sales Invoice Item": ["custom_cost_code"],
      },
      // The rules or the preset call it filled, but your site doesn't fill it, or only guesses: required.
      required: { "Sales Invoice": ["debit_to"] }, // no default receivable account on your site
    },
  },
});

A field you list moves out of the preset's other list, so you don't have to touch the preset. The change applies on the next generate, offline and without a new pull: schema.input is part of what decides whether the generated code is up to date.

Field permissions

A field can sit on a permission level above 0 (permlevel, set in Customize Form), and only the roles granted that level on the doctype can read it. Frappe withholds it from everyone else: a list with fields: ["*"] leaves its column out (frappe/model/db_query.py), and a single fetch drops it, so a number or check comes back as 0 and anything else goes missing (Document.apply_fieldlevel_read_permissions). If your app's API user lacks a level that some field uses, set schema.isPermlevelRestricted: true, and every field above permlevel 0 reads as optional in DocOf and ListEntryOf. If it has every level (Administrator does), leave it off.

Transition overrides

While your site moves to a new version, schema.transitions lets chosen fields also read, or be sent, in the other version's shape until you regenerate from the new snapshot; check --strict fails while one is stale. See Transition overrides in frappe-zod's README.

Credentials

The config names environment variables; it never holds values. pull and check read them from process.env and nothing else: they don't load .env files and never write credentials anywhere.

Sites behind a proxy

When the site sits behind Cloudflare Access, an identity-aware proxy or a gateway that wants an API token, add the headers it expects to the ref; pull and check send them with every request. A header's value is either an environment variable, { env: "NAME" }, for anything secret, or a literal string for what isn't:

schema: {
  refs: {
    prod: {
      urlEnv: "FRAPPE_PROD_URL",
      apiKeyEnv: "FRAPPE_PROD_API_KEY",
      apiSecretEnv: "FRAPPE_PROD_API_SECRET",
      headers: {
        // A Cloudflare Access service token (Zero Trust → Access → Service credentials).
        "CF-Access-Client-Id": { env: "FRAPPE_PROD_CF_ACCESS_CLIENT_ID" },
        "CF-Access-Client-Secret": { env: "FRAPPE_PROD_CF_ACCESS_CLIENT_SECRET" },
        "X-Requested-By": "frappe-zod",
      },
    },
  },
},

Your app's own calls send theirs through createFrappeClient's headers option. init writes the Cloudflare pair when you answer cloudflare at the headers question, and any other header from Header=ENV_VAR. A missing variable fails like a missing API key: pull names it, check --warn and generate --pull-if-missing skip quietly. The Authorization header is built from the API key and secret; set it in headers only for a ref without them, e.g. a proxy that takes a bearer token and passes its own credentials on to Frappe.

pull and check don't follow redirects, so these headers never reach another origin. A redirect to a login page, as Cloudflare Access sends without a valid token, is reported as such. So is an HTML or other non-Frappe response from a proxy that blocks the request; if it blocks by user agent, set userAgent.

Documents

frappe.doc(name) and frappe.docs.<key> return the same API: by doctype name, or by the generated camelCase key (salesInvoice, todo, stockSettings). What it offers depends on the doctype:

| Doctype | Methods | |---|---| | regular | get, find, list, listPage, count, create, update, delete, draftFrom | | submittable | the above plus submit, cancel | | single | get(), update(changes): there is one document, named like the doctype | | child table | nothing: rows are read and written through their parent, so docs leaves them out |

A doctype that wasn't generated is a compile error, and doc() throws for it at runtime.

get and find

const invoice = await frappe.docs.salesInvoice.get("ACC-SINV-2026-00001"); // DocOf<"Sales Invoice">
const maybe = await frappe.docs.salesInvoice.find("ACC-SINV-2026-00001");  // … | undefined

get rejects with a FrappeError whose isNotFound is true when the document doesn't exist; find resolves to undefined instead. Both validate the response with the doctype's doc schema, which is lenient: unknown keys and unknown Select values pass.

Every method that takes a document's name takes NameOf<N>: a string, or also a number for a doctype named by autoincrement, whose name reads as a number (frappe.docs.ledgerHealth.get(7)).

Singles

const settings = await frappe.docs.stockSettings.get();
await frappe.docs.stockSettings.update({ allow_negative_stock: 1 });

A single keeps only the values saved for it, one row per field, and Frappe fills nothing in on read: a field that was never set comes back missing or as "", required fields included, and so can owner, creation, modified and modified_by. Checks, Int, Float, Currency and Percent are cast back to numbers (0 when unset), and the doc schema reads idx and Duration, Rating and Long Int values, which come back as strings, as numbers.

Lists

await frappe.docs.todo.list();                                   // { name: string }[], every row
await frappe.docs.todo.list({ fields: ["name", "status"], limit: 20 });
await frappe.docs.todo.list({ fields: ["*"] });                  // ListOf<"ToDo">

| Option | | |---|---| | fields | default ["name"]; ["*"] for every column; picked fields are typed from ListEntryOf<N> | | filters | [field, operator, value] (field names checked, the value by the operator, see filters; a child table's through its table field, "items.item_code") or [doctype, field, operator, value], all AND | | orFilters | the same, OR | | orderBy | e.g. "modified desc"; "items.qty desc" sorts by a child column (child tables) | | limit, start | rows to return and to skip, as Frappe counts them; without limit the client fetches every matching row. For pages, listPage | | asDict | default true; false returns each row as an array of the picked fields' values (arrays) | | returnNestedChildTables | default true: child columns nested under their table field, one row per document; false returns the SQL join's rows instead, one per child row, the document's fields repeated in each (child tables) | | params | extra query parameters such as group_by, passed as they are; keys the options above set are refused |

A list returns database rows: no virtual fields, no doctype, and of a child table only the columns you pick through it.

Arrays (asDict: false)

const rows = await frappe.docs.todo.list({ fields: ["name", "status", "owner as assignee"], asDict: false, limit: 500 });
// [string, "Open" | "Closed" | …, string][]: ["td-1", "Open", "[email protected]"]
const { page } = await frappe.docs.todo.listPage({ fields: ["name", "status"], asDict: false, pageSize: 20 });

asDict: false sends Frappe's own as_dict=false: each row comes back as an array of the picked fields' values, in the order of fields, so the response doesn't repeat the keys in every row. The client validates each value by its position and types the row as a tuple. listPage wraps the arrays in its pagination as usual.

An array can't say what "*" picked, nor hold nested child rows, so both are refused before anything is sent (their rows type as never). Joined rows work: returnNestedChildTables: false gives one array per child row, its child columns | null.

Filters

await frappe.docs.todo.list({
  filters: [
    ["status", "in", ["Open", "Pending"]],         // or "Open,Pending"
    ["allocated_to", "is", "set"],                 // or "not set"
    ["date", "timespan", "this month"],
    ["modified", "previous", "1 week"],
    ["date", "between", ["2026-01-01", "2026-03-31"]],
    ["description", "like", "%supplier%"],
  ],
});

The value is checked by the operator, as Frappe takes it: is takes "set" or "not set", timespan Frappe's own words ("today", "last 30 days", "this quarter", …), previous and next a period ("1 week" … "1 year"), between a pair, in and not in a list or a comma-separated string, like a pattern, the comparisons a value, and the tree operators (descendants of, …) a name. ["status", "is", "maybe"] is a compile error.

Your editor completes the field and the operator, but the value only as the union over every operator, "set" next to "today": TypeScript narrows an object's properties by one another, never a tuple's elements. The [doctype, field, operator, value] form is checked by neither, so it takes an operator an app adds, e.g. ERPNext's fiscal year.

Picking and renaming fields

const rows = await frappe.docs.salesInvoice.list({
  fields: [
    "name",
    "customer as customer_id",                    // renamed, typed like customer
    "items.qty",                                  // a child column through its table field: items: { qty }[]
    "`tabSales Invoice Item`.item_code",          // a qualified column: unknown
  ],
});
rows[0]?.customer_id;

const perStatus = await frappe.docs.salesInvoice.list({
  fields: ["status", "count(name) as invoices"],  // computed: unknown
  params: { group_by: "status" },
});

A field name the doctype doesn't have is a compile error. Each picked value is validated with the field's list-entry schema, a child column's with the child doctype's; renamed and computed columns that name no field are unknown, and so is a Link field's column (customer.customer_name). Fields known only at runtime, a ListField<"ToDo">[] rather than a literal list, type a row as every field of the list entry, each optional, and any other key as unknown.

Frappe reads an alias only as three words, source as alias, and a dotted source's (allocated_to.full_name as assignee) only with a lowercase as between single spaces. Other forms reject with a TypeError before anything is sent. A field that starts with a backtick or holds a function goes to SQL as written, so "`tabToDo`.priority AS level" and "count(name) as `total count`" are fine. Renaming another column to name is refused as well: a list keeps name for the document's, by which it nests child rows and drops duplicates.

Child tables

Filter on a child table through its table field, as you pick its columns:

await frappe.docs.item.list({ filters: [["supplier_items.supplier", "is", "set"]] });
await frappe.docs.item.count([["supplier_items.supplier", "=", "SUP-0001"]]);

The column is checked against the child doctype (Item Supplier), and the filter is sent as ["Item Supplier", "supplier", "is", "set"]: Frappe reads table_field.column in fields only, and in a filter would look for a column of Item itself by that name. You can write the doctype-qualified form yourself, too. Either way the filter joins the child table, and Frappe then matches a document once per matching child row. The client asks for one row per document (group_by its name), so limit, start and listPage count documents. Frappe joins a child doctype by its parent only, not by the table field, so two table fields of the same child doctype filter alike.

Pick a child column through its table field, and each document comes with its rows nested under that field:

const items = await frappe.docs.item.list({
  fields: ["name", "item_name", "supplier_items.supplier", "supplier_items.supplier_part_no as part"],
  filters: [["supplier_items.supplier", "=", "SUP-0001"]],
  limit: 20,
});
// { name: string; item_name: string | null; supplier_items: { supplier: string; part: string | null }[] }[]

That takes two steps. The first lists the documents with their own fields, filtered, sorted and limited as you asked, so limit, start and listPage count documents. The second fetches the rows of exactly those documents, filtered by their names: one request per child doctype, not paged (limit_page_length=0), the rows in their table's order (idx). Each document gets every row of its table, not only the rows a filter on that table matched: the filter chooses documents. A document without rows gets [], and the child columns are typed as the child doctype's list entry has them, not | null. The key is the column's name, or its alias. A name you didn't pick is requested for the second step and left out again.

Frappe joins a child doctype's table once, whichever of its table fields names it, and by the parent only, not by the table field (frappe/model/db_query.py). So the second step asks for each row's parentfield and sorts the row into its own table field: a Department's leave_approvers and expense_approvers, both of Department Approver, stay apart. Without a limit, the first step pages through every document and the second splits their names over several requests (see long in filters).

A nested list returns documents, so it refuses an orderBy on a child column and a group_by in params, before anything is sent. Both work with returnNestedChildTables: false.

Joined rows

With returnNestedChildTables: false you get the rows as the SQL join returns them, in one step:

await frappe.docs.item.list({ fields: ["name", "supplier_items.supplier"], returnNestedChildTables: false });
// { name: string; supplier: string | null }[]: one row per child row

A picked child column ("supplier_items.supplier" through the table field, or the same column qualified by its table, "`tabItem Supplier`.supplier", which comes joined whatever returnNestedChildTables says) joins that table, and the rows are one per child row, with the document's own fields repeated in each, whether or not a filter is on that table. The key is the column's name (supplier), or its alias ("supplier_items.supplier as sup"). The join is a left join: without a filter on that table, a document with no child rows comes once, its child columns null. Nothing is grouped or dropped, and limit and start count those rows; count still counts documents, and listPage refuses joined rows (pages). Since the join is by parent only, a column of one table field also returns the rows of another of the same child doctype. A group_by of your own in params replaces the client's.

orderBy takes a child column through its table field too ("supplier_items.supplier asc"). Frappe passes order_by to SQL as written, so the client sends it qualified by the child's table (`tabItem Supplier`.`supplier` asc), and Frappe refuses it unless the list joins that table by a picked column or a filter.

Every row, page by page

Without limit, list requests 500 rows at a time until a page comes back empty, so a site that caps the page length lower still returns everything.

Frappe sends no ORDER BY unless asked, and offsets over an unordered query can skip or repeat rows. So the pages are ordered by your orderBy, then by the document's name, and by the child row's name too for joined rows. Documents inserted or deleted while the pages are fetched can still shift the pages. A single slice (limit) goes out with your orderBy as it is, and so does a list with a group_by of your own: to page with start, pass an orderBy that ends in a unique column. listPage does that for you.

A virtual doctype's controller may ignore offsets (RQ Job's does) and answer every page with the first. When a page of distinct rows repeats the one before, list rejects with a FrappeError saying so: pass a limit to get one page.

Long in filters

await frappe.docs.item.list({ filters: [["name", "in", thousandsOfSkus]] });

An in filter whose encoded length would exceed 3,000 bytes is split over several requests, the other filters repeated in each, and the rows concatenated. Duplicate values are dropped first. Across chunks:

  • orderBy holds only within each chunk, and limit caps the total. Both together are refused: the first rows of each chunk aren't the first rows of the result.
  • start and listPage are refused: skipping rows of the first chunk isn't skipping rows of the result, and a page of the first chunk isn't a page of the result.
  • When the split filter is on a child table (["Sales Invoice Item", "item_code", "in", …]), one document can match in several chunks. Without child columns picked it is returned once, deduplicated by name (requested and removed again when you didn't pick it), and count lists the names instead of summing counts. With joined rows, every child row matches exactly one chunk, and every row is returned.

Counting

await frappe.docs.todo.count([["status", "=", "Open"]]);          // or { filters, orFilters }

count asks Frappe's own frappe.desk.reportview.get_count, as its list view does, and counts documents: with a filter on a child table, a document counts once however many of its rows match.

Pages

const { page, pagination } = await frappe.docs.item.listPage({
  fields: ["name", "item_name", "supplier_items.supplier"],
  filters: [["supplier_items.supplier", "is", "set"]],
  orderBy: "item_name asc",
  page: 3, // counts from 1
  pageSize: 20,
});
pagination; // { totalCount: 8128, page: 3, pageSize: 20 }
page; // 20 items, each with all its supplier rows

A page is pageSize documents, and totalCount counts documents too, so page, page size and total always describe the same thing, however many child rows each document has. Child columns never shift a page's boundaries, never cut a document's rows in two and never shrink a page: you can pick them freely and page as reliably as without them. list keeps Frappe's own limit and start, which count rows.

How a page is fetched, with child columns picked through their table field (the default):

  1. The count: frappe.desk.reportview.get_count with your filters, each document counted once however many of its rows a child-table filter matches. It runs alongside step 2.
  2. The documents: one list request with your filters, ordered by your orderBy and then by name, so consecutive pages neither overlap nor skip a document. It skips (page - 1) × pageSize rows, returns at most pageSize, and asks only for the document's own fields. A filter on a child table would make Frappe return a document once per matching row, so the request groups by name: still one row per document.
  3. Their rows: for each child doctype you picked columns of, one request for the rows of exactly the documents from step 2 (name in […]), not paged, in their table's order, each row sorted into its own table field.

So a page costs two requests (count and documents) plus one per child doctype picked, and every document on it arrives complete.

With returnNestedChildTables: false (or a qualified `tabItem Supplier`.supplier) the rows are the SQL join's: one per child row, the document's fields repeated in each. A page of those would cut a document's rows in two, and Frappe can't count them to match, so listPage refuses joined rows before anything is sent. A child-table filter alone is fine: the rows are still grouped into documents. To page joined rows, use list instead of listPage, with start and limit, which count rows as Frappe does. The same goes for a group_by in params: its rows are groups, which the count doesn't count, so listPage refuses it.

A page beyond the last is empty. When a long in filter is split over several requests (long in filters), listPage refuses it, the first page too.

Writing

const todo = await frappe.docs.todo.create({ description: "Call the supplier", priority: "High" });
await frappe.docs.todo.update(todo.name, { status: "Closed", modified: todo.modified });
await frappe.docs.todo.delete(todo.name);

Every write is validated with the doctype's input or update schema before it is sent, and the cleaned value is what goes out: server-owned meta (owner, creation, …) and virtual fields are dropped, checks accept booleans. It goes out as { data: … }, since Frappe would take a doctype's own field named data for the whole document. An invalid write rejects with a FrappeValidationError and sends nothing. Send the modified you read and Frappe refuses the update if the document changed.

What Frappe fills in

create takes InputOf<N>, which requires what Frappe's insert checks and doesn't fill itself. Frappe fills some required fields before it checks them, and InputOf<N> makes those optional:

  • a default on the field (Today, __user, a fixed value). The doctype's title field is the exception: it gets its default only when it is the field named title, which Frappe fills from a template such as {customer_name}.
  • checks (0), and Time fields (the current time, whatever their default says)
  • a Select without a default: its first option, unless that is blank or the field is the title
  • the naming series, on doctypes named by one
  • fields fetched through a link (customer_name from customer), when the link is required or has a default: the fetch only runs while the link has a value
  • read-only fields, which the doctype's code computes (totals)
  • Links whose fieldname has a site or user default (company, currency), once you list it in schema.input.siteDefaults

Every other required field has to be sent, numbers included: a required Int, Float or Currency has no value until you give it one. A required table needs at least one row: its type is a plain array, so mapped or filtered rows fit, and create rejects an empty one before it is sent. A doctype named after one of its fields (autoname: field:x) needs that field even where it isn't marked required.

The doctypes' own code fills many more fields in validate (a Sales Invoice's debit_to, an item row's uom). The snapshot can't see code: for ERPNext v15, extend the preset, and list the rest in schema.input.optional. Where the rules or the preset get a field wrong for your site, correct it in your config.

Child table rows

Rows of a child table exist only inside their parent: you read them with the parent, and write them by sending the parent. On update, a table you send replaces the stored rows: rows you leave out are deleted, rows without a name are added, and a row with the name of a stored row replaces that row completely. Frappe rebuilds the row from what you send and writes every column, so a column you leave out becomes empty: NULL, or 0 for numbers and checks. To change one value, send the whole row, for example spread from the fetched document:

import { schemaFor } from "./frappe-zod-generated/index.js";

const invoice = await frappe.docs.salesInvoice.get("ACC-SINV-2026-00001");
const items = invoice.items.map((row) => (row.name === rowName ? { ...row, qty: 3 } : row));
// A fetched row reads its Selects as any string; update.parse checks them against the options, and so types the body.
await frappe.docs.salesInvoice.update(invoice.name, schemaFor("Sales Invoice").update.parse({ items }));

Rows are numbered by their position in the array: create and update drop a row's idx.

Submit and cancel

await frappe.docs.salesInvoice.submit("ACC-SINV-2026-00001"); // docstatus 1
await frappe.docs.salesInvoice.cancel("ACC-SINV-2026-00001"); // docstatus 2

Cancelling a document that is already cancelled (a retry, say) returns it instead of failing: when a cancel fails, the client reads the document, returns it if it is cancelled, and otherwise fails with the cancel's error. To create a document submitted, pass docstatus: 1 to create. Frappe refuses to create one cancelled, so create doesn't take docstatus: 2: cancel with cancel, or update to 2. Documents of doctypes that aren't submittable always have docstatus 0, and neither create nor update takes another.

A submitted document still takes changes to fields marked Allow on Submit, and only to those: anything else fails with Frappe's UpdateAfterSubmitError. update checks a change against UpdateOf<N>, which doesn't know the document is submitted; UpdateAfterSubmitOf<N> and schemaFor(N).updateAfterSubmit hold just what may change, and update takes them:

  • the fields with allow on submit, a table among them included: its rows may be added and removed
  • the rows of a table without it whose child doctype has allow-on-submit fields. The table keeps its rows, so send each stored row whole and with its name, changing only those fields.

Drafts from mapping methods

import { frappe } from "./frappe";
import { schemaFor } from "./frappe-zod-generated/index.js";

const draft = await frappe.docs.salesInvoice.draftFrom(
  "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
  { source_name: "SAL-ORD-2026-00001" },
); // DraftOf<"Sales Invoice">
await frappe.docs.salesInvoice.create(schemaFor("Sales Invoice").input.parse({ ...draft, due_date: "2026-11-30" }));

A mapping method turns one document into a new, unsaved one: it has no name, creation or modified yet, carries __islocal: 1, and leaves required fields empty that the mapping can't know (a due date). draftFrom validates it with the doctype's draft schema; fill in the gaps and pass it through input, which drops the draft markers.

Methods

import { z } from "zod/v4";

const user = await frappe.method("frappe.auth.get_logged_user", { method: "GET", responseValidation: z.string() });
await frappe.method("frappe.desk.doctype.tag.tag.add_tag", { body: { dt: "ToDo", dn: todo.name, tag: "urgent" } });

method(path, options) calls /api/method/<path> and resolves to the response's message. The method option is POST by default; PUT and PATCH work the same way, and GET takes no body. params go in the query, JSON-encoding everything but strings; body is sent as JSON. Frappe accepts GET, POST and PUT for a whitelisted method unless it lists its own, so PATCH only where it's listed.

The validation options take a zod schema from zod/v4 (on zod 3.25, a schema from plain zod is zod 3's, which they don't take). With responseValidation, the message is validated and typed by it; without, it's unknown. With paramsValidation or bodyValidation, params or body are required, typed by that schema and checked against it before anything is sent; they go out as given, not as parsed:

const StockQuery = z.object({ sku: z.string(), warehouse: z.string().optional() });
const qty = await frappe.method("my_app.api.stock", { method: "GET", params: { sku: "SKU-1" }, paramsValidation: StockQuery, responseValidation: z.number() });

For a method that answers beside message, such as frappe.desk.form.load.getdoc, isAutoWrapResponseValidationWithMessage: false resolves to the whole body, which responseValidation then checks:

const Loaded = z.object({ docs: z.array(z.looseObject({ name: z.string() })) });
const { docs } = await frappe.method("frappe.desk.form.load.getdoc", { method: "GET", params: { doctype: "ToDo", name: "x" }, responseValidation: Loaded, isAutoWrapResponseValidationWithMessage: false });

To build a request yourself, request sends it as built and resolves to the whole body, unvalidated:

const body = await frappe.request({ method: "GET", path: "/api/method/frappe.desk.form.load.getdoc", query: { doctype: "ToDo", name: "x" } });

Files

const file = await frappe.files.upload({
  content: pdfBuffer,                          // Uint8Array / Buffer or ArrayBuffer
  filename: "label.pdf",
  attachTo: { doctype: "Delivery Note", name: "MAT-DN-2026-00001" },
  isPrivate: true,                             // the default
});
const bytes = await frappe.files.download(file.file_url); // Uint8Array

Uploads and downloads go through the file transport: the fileTransport you pass to createClient, else the main transport. fetchTransport carries both.

Uploads go to Frappe's upload_file as multipart, so the transport needs upload. Uploading the same content again is never an error, and always creates a File record of its own. When a File with the same content and privacy already has its file on the site's disk, the new record points at that file's URL instead of storing the content again (File.save_file); validate_duplicate_entry then prefers such a file attached to the same document. Storage apps such as S3 attachments clear content_hash and remove the file from the disk, so with them the content may be stored again. The result is typed DocOf<"File"> when your registry includes File.

Downloads need a transport that can return bytes. fetchTransport sends your credentials only to your site and follows a redirect to object storage without them.

Transports

Request building (paths, encoding, filters, field picking, pagination, chunking, validation) lives in the client. A transport only carries a finished request:

interface FrappeRequest {
  method: "GET" | "POST" | "PUT" | "DELETE";
  path: string;                       // "/api/resource/Sales%20Invoice/ACC-SINV-0001"
  query?: Record<string, string>;
  body?: unknown;                     // JSON, or FormData for an upload
  options?: Record<string, unknown>;  // hints such as a queue priority; ignored when unknown
}

interface Transport {
  send(request: FrappeRequest): Promise<unknown>;          // the parsed JSON body; rejects with FrappeError
  upload?(request: FrappeRequest): Promise<unknown>;       // optional: a FormData body, sent as multipart
  download?(request: FrappeRequest): Promise<Uint8Array>;  // optional
}

createFrappeClient uses fetchTransport. For another transport, build the client with createClient:

import { createClient, fetchTransport } from "frappe-zod-client";
import * as generated from "./frappe-zod-generated/index.js";

const frappe = createClient({
  generated,
  transport: fetchTransport({ url: "https://erp.example.com", apiKey, apiSecret, headers: { "CF-Access-Client-Id": id } }),
});

transport may also be a function, called on the first request. fileTransport carries uploads and downloads when the main transport can't, e.g. a queue that only carries JSON next to a direct fetchTransport. checkedFetchTransport takes createFrappeClient's options, which may be undefined, and throws a ConfigError naming what's missing, so fileTransport: () => checkedFetchTransport({ url: process.env.FRAPPE_URL, … }) fails on the first file request, not on import.

A transport's own options are typed through the client: with a Transport<{ priority?: number }>, every method takes them as its last argument (get(name, { priority: 1 }), list(query, { priority: 1 }), method(path, { body, priority: 1 })), and they reach the transport as request.options.

fetchTransport retries rate limits (429) for every method, and 502/503/504 and dropped connections only for reads, where repeating is safe.

Errors

import { FrappeError, FrappeValidationError } from "frappe-zod-client";

try {
  await frappe.docs.salesInvoice.submit(name);
} catch (err) {
  if (err instanceof FrappeError) {
    err.status;    // 417
    err.excType;   // "ValidationError"
    err.messages;  // ["Row 1: Qty must be positive"], HTML stripped
    err.isNotFound;
  }
  if (err instanceof FrappeValidationError) err.issues; // zod issues of a write or an unreadable response
}
  • FrappeError: the site answered with an error, or didn't answer (status 0). When something other than Frappe answered (a proxy, a firewall), the message says so.
  • FrappeValidationError: a write that doesn't match the schema (nothing was sent), or a response that doesn't. subject names what failed, e.g. ToDo input or Sales Invoice list row 3.

instanceof on either class also recognizes errors from a second copy of this package (an ESM and a CommonJS copy side by side). A subclass you derive keeps the ordinary check: a plain FrappeError is not an instance of it.

Types

After generate, these types know every doctype of your installation by name. N is a doctype name such as "Sales Invoice"; anything else is a compile error. They come from frappe-zod, and frappe-zod-client re-exports them, so import them from here:

import type { CapabilitiesOf, DocOf, DocTypeName, DraftOf, InputOf, ListEntryOf, ListOf, NameOf, UpdateOf } from "frappe-zod-client";

| Type | What it is | |---|---| | DocOf<N> | a saved document: what get, find, create, update, submit and cancel resolve to | | ListEntryOf<N> | a list row with every column | | ListEntryOf<N, F> | a list row with the fields F, a union of field names: Pick<ListEntryOf<N>, F> | | ListOf<N>, ListOf<N, F> | the rows of a list, ListEntryOf<N>[] and ListEntryOf<N, F>[]; ListOf<N> is what list({ fields: ["*"] }) returns | | InputOf<N> | what create takes | | UpdateOf<N> | what update takes: any part of the input | | UpdateAfterSubmitOf<N> | what a submitted document still takes (allow on submit); never for doctypes that aren't submittable | | DraftOf<N> | an unsaved document from a mapping method: what draftFrom resolves to | | NameOf<N> | what names a document: string, or string \| number where name is an autoincrement number | | CapabilitiesOf<N> | { key, app, module, submittable, single, child, custom }, which decide the methods of its API | | DocTypeName | the union of all doctype names | | SchemaSetOf<N> | the runtime schemas of one doctype | | DocTypeRegistry | the interface the generated code augments, by doctype name |

What every document carries

Besides its own fields, every saved document has the same meta fields:

| Field | Meaning | |---|---| | name | the primary key: from a naming series (ACC-SINV-2026-00001), a field, or random; a number for doctypes named by autoincrement | | owner, creation | who created it, and when | | modified, modified_by | who changed it last, and when | | docstatus | 0 draft, 1 submitted, 2 cancelled | | idx | position; meaningful for child rows | | doctype | the doctype's name, on single fetches and inside child rows (not in lists) | | _user_tags, _liked_by | tags and likes, only present once set | | _comments, _assign, _seen | comments, assignees and viewers: columns of a list row, never part of a single fetch |

Child rows carry them too, plus parent (the parent's name), parenttype (its doctype) and parentfield (the table field). DocOf<N> includes what a single fetch returns, ListEntryOf<N> what a list row does. A single fetch also returns virtual fields: values the server computes on the fly, never stored, listed or writable.

DocOf

const invoice: DocOf<"Sales Invoice"> = await frappe.docs.salesInvoice.get(name);
invoice.name;                  // string
invoice.docstatus;             // 0 | 1 | 2
invoice.company;               // string: ERPNext requires it
invoice.customer;              // string | null | undefined: ERPNext doesn't
invoice.status;                // "Draft" | "Return" | … | (string & {}) | null | undefined: unknown options still parse
invoice.grand_total;           // number, always present
invoice.items[0]?.parent;      // string: child rows carry their meta

ListEntryOf and ListOf

A list returns database rows: no child tables, no virtual fields, no doctype. Empty values come back as null, while a single fetch leaves empty fields out. ListEntryOf<N> is the row with every column; ListEntryOf<N, F> picks fields from it. ListOf<N, F> is the rows, ListEntryOf<N, F>[], and names what a list call with plain fields (no aliases, no child columns) returns, which fits it:

const FIELDS = ["name", "item_name", "disabled"] as const;
type ItemRow = ListEntryOf<"Item", (typeof FIELDS)[number]>;   // { name: string; item_name: …; disabled: 0 | 1 }

const items: ListOf<"Item", (typeof FIELDS)[number]> = await frappe.docs.item.list({ fields: FIELDS });
function label(item: ItemRow): string { … }

type Table = ListOf<"Item", "supplier_items">;                 // error: lists have no child tables

For aliases and child columns, ListRow types a row the way list does.

InputOf and UpdateOf

const todo: InputOf<"ToDo"> = { description: "Call the supplier", priority: "High" };
const wrong: InputOf<"ToDo"> = { description: "x", status: "Done" }; // error: not an option

const close: UpdateOf<"ToDo"> = { status: "Closed", modified: todo.modified };
const submit: UpdateOf<"Sales Invoice"> = { docstatus: 1 };
const nope: UpdateOf<"ToDo"> = { docstatus: 1 }; // error: ToDo isn't submittable

DraftOf

const draft: DraftOf<"Sales Invoice"> = await frappe.docs.salesInvoice.draftFrom(method, { source_name });
draft.name;      // string | undefined, there is none until it's saved
draft.customer;  // string | null | undefined, a draft can be incomplete

CapabilitiesOf and DocTypeName

type SubmittableDoctype = {
  [N in DocTypeName]: CapabilitiesOf<N>["submittable"] extends true ? N : never;
}[DocTypeName];

const ok: SubmittableDoctype = "Sales Invoice";
const no: SubmittableDoctype = "ToDo"; // error

The client's own types

| Type | What it is | |---|---| | FrappeClient<O> | the client; O is its transport's options (Transports) | | DocApi<N, O> | what frappe.doc(N) returns: RegularDocApi, SubmittableDocApi, SingleDocApi or ChildDocApi, by the doctype's capabilities | | FrappeClientOptions | createFrappeClient's options | | ListRow<N, F> | a row of list({ fields: F }), F the fields' tuple (typeof FIELDS): aliases and child columns included | | ListTuple<N, F> | a row of list({ fields: F, asDict: false }) | | ListPage<T> | what listPage resolves to: { page: T[], pagination } | | ListQuery<N>, ListPageQuery<N>, CountQuery<N> | what list, listPage and count take | | ListField<N> | a field list takes: a field name, "*", an alias, a child column | | FieldOf<N> | the columns a list can filter, sort or pick: keyof ListEntryOf<N> | | TableFieldOf<N>, TableColumnOf<N> | N's table fields (supplier_items), and the child columns through them (supplier_items.supplier) | | Filter<N>, FilterOperator, FilterValue, Timespan, FilterPeriod | a filter and its parts | | MethodOptions, MethodInput | what method takes, and the schema type its paramsValidation and bodyValidation take | | FileUpload, UploadedFile | what files.upload takes, and the File it resolves to | | Transport<O>, FrappeRequest<O>, FetchTransportOptions | a transport, the request it carries, and fetchTransport's options |

const FIELDS = ["name", "customer as customer_id", "items.qty"] as const;
type InvoiceRow = ListRow<"Sales Invoice", typeof FIELDS>; // { name: string; customer_id: …; items: { qty: number }[] }

Per-doctype modules

Every doctype also has its own module under <outDir>/<app>/<doctype>.js; doctypes created in the UI are under custom/. It exports the same types without the registry, plus its schemas:

import * as SalesInvoice from "./frappe-zod-generated/erpnext/sales_invoice.js";

function grandTotal(invoice: SalesInvoice.Doc): number {
  return invoice.grand_total;
}
SalesInvoice.doc.parse(data);   // also .input, .update, .draft, .listEntry, and .updateAfterSubmit if submittable
// types: SalesInvoice.Doc, .Input, .Update, .Draft, .UpdateAfterSubmit; a list row is ListEntryOf<"Sales Invoice">

Runtime schemas

The client validates with these; the generated module exports them for your own use:

import { doctypes, keys, schemaFor, schemas } from "./frappe-zod-generated/index.js";

schemaFor("Sales Invoice").doc.parse(data);   // by name, typed through the registry; throws for unknown names
schemas["Sales Invoice"].input.parse(body);   // the same schemas as a map
doctypes["Sales Invoice"].submittable;        // true; also key, app, module, single, child, custom
keys.salesInvoice;                            // "Sales Invoice"

Each doctype has six schemas:

| Schema | Validates | Unknown keys | Notes | |---|---|---|---| | doc | a single fetch | kept | the read rules | | listEntry | a list row with fields: ["*"] | kept | | | input | a create body | rejected | drops server-owned meta, so a fetched document or a draft can be sent back | | update | an update body | rejected | every field optional | | updateAfterSubmit | an update body for a submitted document | rejected | only what may change after submit; rejects everything where the doctype isn't submittable | | draft | a mapping method's result | kept | every field optional |

Schemas are built the first time you use a doctype, so 1,000 doctypes cost almost nothing at startup. They're the same zod as yours, so they compose: z.object({ invoice: schemaFor("Sales Invoice").doc }).

CLI

frappe-zod-client is frappe-zod's CLI, with this package's init:

| Command | Network | Does | |---|---|---| | frappe-zod-client init [--yes] [--force] | no | asks for the installations to read, writes the config, offers zod, the scripts and the .gitignore entry, and shows the module that creates your client | | frappe-zod-client pull [--ref r] | GET only | reads every doctype's merged definition, rewrites the snapshot if anything type-relevant changed, prints the diff with breaking changes marked | | frappe-zod-client generate [--ref r] [--force] [--pull-if-missing] | only to pull a missing snapshot | generates from the snapshot; skipped when nothing changed | | frappe-zod-client check [--ref r] [--full] [--warn] [--strict] | GET only | exit 1 when the installation drifted from the snapshot |

  • --ref falls back to $FRAPPE_ZOD_REF, then defaultRef.
  • generate --pull-if-missing pulls first when there is no snapshot yet; if that isn't possible (no credentials, site unreachable) it warns and exits 0, so an install never fails on it. An existing snapshot is never refreshed on install.
  • check asks the site what changed since the pull and re-reads only those doctypes. A deleted property setter leaves no trace in that list, so run check --full in a scheduled job.
  • A doctype moved into or out of an excluded app or module shows as entering or leaving the selection; leaving it is breaking, since the doctype leaves the generated code.
  • check --warn reports drift but exits 0, and stays silent without credentials: made for predev.
  • check --strict also exits 1, as for drift, while a transition override is stale.
  • Exit codes: 0 ok, 1 drift (or, with --strict, a stale transition override), 2 error.

Generation rules

The snapshot holds each doctype's merged definition: its fields, with the custom fields and property setters added in the UI. The generated types and schemas follow it like this:

  • Reads are lenient. Unknown keys are kept and a Select accepts values it doesn't know yet, typed as the known options plus string, so a change in the UI can't break parsing in production. Int, Float, Currency and Percent fields, checks and child tables are always present, as Frappe returns 0 / [], except fields above permlevel 0 with isPermlevelRestricted. Duration, Rating and Long Int columns can be empty: a single fetch leaves them out and a list returns null, so they read as optional number | null.
  • Reads trust only a doctype's own requirements. Frappe checks required fields on save and never backfills, so documents saved before a field became required lack it. Fields made required later in the UI (custom fields, property setters, every field of a doctype created in the UI) are optional on read; fields the app itself requires stay required. A single doctype's required fields are optional on read too.
  • Writes are strict. A required field is required, wherever the requirement comes from, unless Frappe fills it on insert (what it fills) or the config's schema.input says otherwise. Select values must be known options; "" and null are accepted for optional ones, exactly as Frappe validates. Unknown keys are rejected; checks accept booleans.
  • Writes accept what reads return. input and update drop server-owned meta (owner, creation, modified_by, the idx of the document and of its rows, __islocal, …) and virtual fields, and keep modified for Frappe's conflict check.
  • Virtual fields are on DocOf only, as T | null: not in list entries, not writable.
  • Trees (doctypes with lft and rgt, such as Account or Item Group) keep lft, rgt and old_parent out of input and update, which drop them when a copied document carries them: Frappe computes the nested set, and a node inserted with another node's lft and rgt corrupts it. Move a node by updating its parent field (parent_account, parent_item_group, …).
  • Single doctypes are read as stored: required fields, owner, creation, modified and modified_by are optional, while name, docstatus, idx, checks, Int, Float, Currency and Percent stay present. They return idx, and Duration, Rating and Long Int values, as strings; doc reads them as numbers. Writes follow the usual rules.
  • Virtual doctypes are built by their controller, which may leave out or null what it doesn't have: owner, creation, modified and modified_by read as optional and nullable, docstatus and idx as optional.
  • naming_series and Selects without options are plain strings; unknown fieldtypes are unknown.
  • JSON fields read as text: Frappe returns the stored JSON as a string. Writes take a string or an object, which Frappe serialises, but never a list (Frappe refuses one).
  • Ratings are fractions from 0 to 1 (3 of 5 stars is 0.6). Frappe clamps a saved rating to that range, so a 4 would be stored as 1; input and update reject values outside it.
  • Generated code imports zod/v4. Output is ESM or CommonJS following your package.json.