frappe-zod-client
v0.2.1
Published
A Frappe / ERPNext REST client typed by the schemas frappe-zod generates from your installation
Maintainers
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 submittableThere 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 zodzod 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 initName 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 .gitignoreWithout 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"); // … | undefinedget 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 rowA 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:
orderByholds only within each chunk, andlimitcaps the total. Both together are refused: the first rows of each chunk aren't the first rows of the result.startandlistPageare 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 byname(requested and removed again when you didn't pick it), andcountlists 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 rowsA 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):
- The count:
frappe.desk.reportview.get_countwith your filters, each document counted once however many of its rows a child-table filter matches. It runs alongside step 2. - The documents: one list request with your filters, ordered by your
orderByand then byname, so consecutive pages neither overlap nor skip a document. It skips(page - 1) × pageSizerows, returns at mostpageSize, 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. - 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 namedtitle, 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_namefromcustomer), 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 inschema.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 2Cancelling 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); // Uint8ArrayUploads 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 (status0). 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.subjectnames what failed, e.g.ToDo inputorSales 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 metaListEntryOf 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 tablesFor 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 submittableDraftOf
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 incompleteCapabilitiesOf and DocTypeName
type SubmittableDoctype = {
[N in DocTypeName]: CapabilitiesOf<N>["submittable"] extends true ? N : never;
}[DocTypeName];
const ok: SubmittableDoctype = "Sales Invoice";
const no: SubmittableDoctype = "ToDo"; // errorThe 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 |
--reffalls back to$FRAPPE_ZOD_REF, thendefaultRef.generate --pull-if-missingpulls 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.checkasks the site what changed since the pull and re-reads only those doctypes. A deleted property setter leaves no trace in that list, so runcheck --fullin 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 --warnreports drift but exits 0, and stays silent without credentials: made forpredev.check --strictalso 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 returns0/[], except fields above permlevel 0 withisPermlevelRestricted. Duration, Rating and Long Int columns can be empty: a single fetch leaves them out and a list returnsnull, so they read as optionalnumber | 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.inputsays otherwise. Select values must be known options;""andnullare accepted for optional ones, exactly as Frappe validates. Unknown keys are rejected; checks accept booleans. - Writes accept what reads return.
inputandupdatedrop server-owned meta (owner,creation,modified_by, theidxof the document and of its rows,__islocal, …) and virtual fields, and keepmodifiedfor Frappe's conflict check. - Virtual fields are on
DocOfonly, asT | null: not in list entries, not writable. - Trees (doctypes with
lftandrgt, such as Account or Item Group) keeplft,rgtandold_parentout ofinputandupdate, which drop them when a copied document carries them: Frappe computes the nested set, and a node inserted with another node'slftandrgtcorrupts it. Move a node by updating its parent field (parent_account,parent_item_group, …). - Single doctypes are read as stored: required fields,
owner,creation,modifiedandmodified_byare optional, whilename,docstatus,idx, checks, Int, Float, Currency and Percent stay present. They returnidx, and Duration, Rating and Long Int values, as strings;docreads 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,modifiedandmodified_byread as optional and nullable,docstatusandidxas optional. naming_seriesand Selects without options are plain strings; unknown fieldtypes areunknown.- 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;inputandupdatereject values outside it. - Generated code imports
zod/v4. Output is ESM or CommonJS following yourpackage.json.
