A TypeScript library for parsing, analyzing, and generating type-safe SDKs from SpiceDB's .zed schema files.
This library provides a complete toolchain for working with SpiceDB schemas, transforming its schema DSL into type-safe TypeScript APIs. It consists of three main components:
- Schema Parser - Parses
.zedfiles into structured ASTs using Chevrotain - Semantic Analyzer - Performs semantic analysis and type inference on the parsed AST
- SDK Generator - Creates type-safe TypeScript SDKs from analyzed schemas
Additionally, it includes a Fluent Builder Library that provides an ergonomic API for SpiceDB operations, serving as a bridge between the verbose @authzed/authzed-node gRPC client and your type-safe generated SDK.
Convert string-based SpiceDB operations into compile-time checked TypeScript:
// ❌ Error-prone: strings everywhere, no compile-time validation
await client.checkPermission({
resource: {
objectType: "document",
objectId: "doc1",
},
permission: "edit", // Could be misspelled
subject: {
object: {
objectType: "user",
objectId: "alice",
},
},
});
// ✅ Type-safe: generated from your schema
await permissions.document.check
.edit("user:alice", "document:doc1")
.execute(spicedbClient);Catch schema errors early with comprehensive semantic analysis:
- Undefined type references
- Circular dependencies
- Invalid permission expressions
- Duplicate definitions
Replace verbose gRPC objects with fluent, chainable APIs:
The builder layer is intentionally string-based — drop down to it when you need dynamic relations or operations whose shape isn't known until runtime. The generated SDK is a thin, type-safe facade over these same primitives.
// ❌ Verbose gRPC style
await client.writeRelationships({
updates: [
{
operation: RelationshipUpdate_Operation.TOUCH,
relationship: {
resource: { objectType: "document", objectId: "doc1" },
relation: "editor",
subject: { object: { objectType: "user", objectId: "alice" } },
},
},
],
});
// ✅ Fluent builder style
await perms
.grant("editor")
.subject("user:alice")
.resource("document:doc1")
.execute();Automatically generate SDKs that stay in sync with schema changes, preventing runtime errors when schemas evolve.
pnpm install @schoolai/spicedb-zed-schema-parserHere's a complete example of parsing a schema and generating a type-safe SDK:
import fs from "node:fs/promises";
import {
parseSpiceDBSchema,
analyzeSpiceDbSchema,
generateSDK,
} from "@schoolai/spicedb-zed-schema-parser";
async function generatePermissionsSDK() {
// 1. Read your schema file
const schemaContent = await fs.readFile("schema.zed", "utf-8");
// 2. Parse the schema
const { ast, errors: parseErrors } = parseSpiceDBSchema(schemaContent);
if (parseErrors.length > 0) {
console.error("Parse errors:", parseErrors);
return;
}
// 3. Analyze the schema
const {
augmentedAst,
errors: analysisErrors,
isValid,
} = analyzeSpiceDbSchema(ast!);
if (!isValid) {
console.error("Analysis errors:", analysisErrors);
return;
}
// 4. Generate TypeScript SDK
const generatedCode = generateSDK(augmentedAst!);
// 5. Write to file
await fs.writeFile("generated/permissions.ts", generatedCode);
console.log("✅ Type-safe permissions SDK generated!");
}definition user {}
definition document {
/** @check: isOwnedBy */
relation owner: user
relation editor: user
relation viewer: user
permission edit = owner + editor
permission view = owner + editor + viewer
}
definition folder {
relation owner: user
relation editor: user
relation parent: folder
permission edit = owner + editor + parent->edit
permission view = owner + editor + parent->view
}Tip: the
@check: <name>directive in a relation's doc comment renames its auto-generated relation check (see Generated SDK Usage). Without it,relation owner: userwould surface aspermissions.document.check.isOwner(...); with it, you getpermissions.document.check.isOwnedBy(...).
For each definition with relations or permissions, the generator emits a typed surface under permissions.<type>:
| Surface | What it does |
|---|---|
permissions.<type>.grant.<relation>(subject, resource) |
Grant a relation — subject accepts a single ref or array |
permissions.<type>.revoke.<relation>(subject, resource) |
Revoke a relation — also accepts arrays |
permissions.<type>.check.<permission>(subject, resource) |
Type-safe permission check |
permissions.<type>.check.is<Relation>(subject, resource) |
Auto-generated relation check (rename via @check:) |
permissions.<type>.checkBulk(permission, subject, resources[]) |
Single CheckBulkPermissions gRPC call |
permissions.<type>.find.by<Relation>(subject) |
Find resources where subject holds <Relation> |
permissions.<type>.lookup.resources(subject, permission) |
LookupResources for accessible objects |
permissions.<type>.lookup.subjects(resource, permission, subjectType) |
LookupSubjects for who can do something |
permissions.<type>.deleteAll({ relation?, subjectType?, subjectId?, resourceId? }) |
Filtered bulk delete |
permissions.batch(...operations) |
Combine writes into a single transaction |
dynamicGrant / dynamicRevoke / dynamicCheck + GrantParams / RevokeParams / CheckParams |
Runtime dispatch with discriminated-union safety |
The generated SDK provides type-safe methods for every schema surface above. Subject and resource literals are validated at compile time from the relations declared in your schema.
Every generated method returns a pure Operation<T> — pass your SpiceDB client to .execute(client) to run it. (For a pre-bound .execute() with no args, use createPermissions(client).)
import { permissions } from "./generated/permissions";
// Single subject
await permissions.document.grant
.editor("user:alice", "document:doc1")
.execute(spicedbClient);
// Multiple subjects in one call — produces one TOUCH update per subject
await permissions.document.grant
.editor(["user:alice", "user:bob"], "document:doc1")
.execute(spicedbClient);
await permissions.document.revoke
.viewer("user:charlie", "document:doc1")
.execute(spicedbClient);
// ❌ Compile-time errors for invalid operations
await permissions.document.grant.invalidRelation("user:alice", "document:doc1"); // Error!
await permissions.document.check.edit("invalid:type", "document:doc1"); // Error!// Permission check
await permissions.document.check
.view("user:bob", "document:doc1")
.execute(spicedbClient);
// Auto-generated relation check (defaults to `is<Relation>`, overridden by `@check:`)
await permissions.document.check
.isOwnedBy("user:alice", "document:doc1")
.execute(spicedbClient);
// With strong consistency from a previous write token
await permissions.document.check
.view("user:bob", "document:doc1")
.withConsistency(zedToken)
.execute(spicedbClient);Check a single permission against many resources in one gRPC round-trip:
const results = await permissions.document
.checkBulk("view", "user:alice", [
"document:doc1",
"document:doc2",
"document:doc3",
])
.execute(spicedbClient);
// → [{ resourceId: "doc1", hasPermission: true }, ...]// Find: which documents does alice own?
const owned = await permissions.document.find
.byOwner("user:alice")
.execute(spicedbClient);
// Lookup resources accessible by a subject (LookupResources)
const viewable = await permissions.document.lookup
.resources("user:alice", "view")
.execute(spicedbClient);
// Lookup subjects with access to a resource (LookupSubjects)
const editors = await permissions.document.lookup
.subjects("document:doc1", "edit", "user")
.execute(spicedbClient);// Revoke everything alice has on document:doc1
await permissions.document
.deleteAll({ relation: "editor", subjectType: "user", subjectId: "alice" })
.execute(spicedbClient);
// Wipe all relationships for a deleted resource
await permissions.document.deleteAll({ resourceId: "doc1" }).execute(spicedbClient);permissions.batch(...) combines any number of grant/revoke operations into a single
WriteRelationships call and returns { token, succeeded, operationCount }:
const result = await permissions
.batch(
permissions.document.grant.editor("user:alice", "document:doc1"),
permissions.document.grant.viewer(["user:bob", "user:charlie"], "document:doc1"),
permissions.folder.revoke.editor("user:dave", "folder:f1"),
)
.execute(spicedbClient);For admin tools, UI builders, or anywhere the operation shape isn't known until runtime, the generator emits discriminated unions and dispatcher functions:
import {
dynamicGrant,
dynamicCheck,
type GrantParams,
type CheckParams,
} from "./generated/permissions";
const params: GrantParams = {
objectType: "document", // narrows allowed `relation` and `subject` types
relation: "editor",
subject: "user:alice",
resource: "document:doc1",
};
await dynamicGrant(params).execute(spicedbClient);TypeScript enforces, per objectType, that relation, subject, and resource match the
schema — you can't accidentally grant a folder relation with a document resource.
For cases where you need dynamic operations or are migrating from string-based APIs, use the fluent builder. Every method comes in two flavors:
createPermissions(client)returns aPermissionsinstance with the client already bound — call.execute()with no args.Operations.*static builders produce pureOperation<T>values you can serialize, store, or run later withop.execute(client)/perms.execute(op).
import {
createPermissions,
Operations,
} from "@schoolai/spicedb-zed-schema-parser";
const perms = createPermissions(spicedbClient);
// Single grant
await perms
.grant("editor")
.subject("user:alice")
.resource("document:doc1")
.execute();
// Multi-subject grant in a single gRPC call
await perms
.grant("viewer")
.subject(["user:alice", "user:bob", "user:charlie"])
.resource("document:doc1")
.execute();
// Batch — every .add() is written in one WriteRelationships call
await perms
.batch()
.add(perms.grant("viewer").subject("user:charlie").resource("folder:f1"))
.add(perms.revoke("editor").subject("user:alice").resource("document:doc1"))
.execute();const canView = await perms
.check("view")
.subject("user:bob")
.resource("document:doc1")
.withConsistency(zedToken) // optional strong consistency
.execute();
// Bulk check — one CheckBulkPermissions gRPC call for many resources
const bulk = await Operations.bulkCheck("view", "user:alice", [
"document:doc1",
"document:doc2",
]).execute(spicedbClient);
// → [{ resourceId: "doc1", hasPermission: true }, ...]// Which documents can alice view?
const accessible = await perms
.lookup()
.resourcesAccessibleBy("user:alice")
.withPermission("view")
.ofType("document")
.execute();
// Who can edit document:doc1?
const editors = await perms
.lookup()
.subjectsWithAccessTo("document:doc1")
.withPermission("edit")
.ofType("user")
.execute();
// `withPermissions` fans out one LookupSubjects call per permission in parallel,
// returning a Map<subjectId, highestPermission> with first-wins semantics
const matrix = await perms
.lookup()
.subjectsWithAccessTo("document:doc1")
.ofType("user")
.withPermissions(["edit", "view"]);
// → Map { "alice" => "edit", "bob" => "view" }find() exposes ReadRelationships directly. Wildcards on the subject restrict to a type without an id:
const aliceEditorRows = await perms
.find()
.relation("editor")
.subject("user:alice")
.execute();
// All `collaborator` relationships held by any user
const allUserCollabs = await Operations.find()
.relation("collaborator")
.subject("user:*")
.execute(spicedbClient);const deleteOp = Operations.delete().where({
resourceType: "document",
resourceId: "doc1",
});
await perms.execute(deleteOp);Every operation implements toJSON(), so you can log, persist to an audit table, or replay later:
const op = Operations.grant("editor")
.subject(["user:alice", "user:bob"])
.resource("document:doc1");
JSON.stringify(op);
// {"operation":"grant","relation":"editor","subjects":["user:alice","user:bob"],"resources":["document:doc1"]}Parses a SpiceDB schema string into an AST.
const { ast, errors } = parseSpiceDBSchema(schemaContent);Performs semantic analysis on a parsed schema.
const { augmentedAst, errors, isValid } = analyzeSpiceDbSchema(ast);Generates TypeScript code for a type-safe permissions SDK. parserImport defaults to @schoolai/spicedb-zed-schema-parser/builder and lets monorepo consumers point at a local builder package or a custom re-export.
const generatedCode = generateSDK(augmentedAst);
// or, in a monorepo:
const generatedCode = generateSDK(augmentedAst, "@myorg/spicedb-builder");Creates a permissions instance with bound SpiceDB client.
Provides static methods for creating pure operations. Each returns an Operation<T> that you can serialize, compose, or execute with op.execute(client).
Operations.grant(relation: string)→WriteOperationOperations.revoke(relation: string)→WriteOperationOperations.check(permission: string)→CheckOperationOperations.bulkCheck(permission: string, subject: string, resources: string[])→BulkCheckOperationOperations.find()→QueryOperation(ReadRelationships)Operations.lookup()→LookupOperation(LookupResources/LookupSubjects)Operations.delete()→DeleteOperationOperations.batch()→Transaction
- ✅ Definitions - Object type definitions
- ✅ Relations - Direct relations between objects
- ✅ Permissions - Computed permissions with complex expressions
- ✅ Caveats - Conditional logic (basic support)
- ✅ Union expressions -
permission = rel1 + rel2 - ✅ Intersection expressions -
permission = rel1 & rel2 - ✅ Exclusion expressions -
permission = rel1 - rel2 - ✅ Arrow expressions -
permission = rel->permission - ✅ Wildcard relations -
relation public: user:* - ✅ Sub-relations -
relation editor: user#admin - ✅ Doc comments -
/** documentation */
The library provides comprehensive error reporting:
const { ast, errors } = parseSpiceDBSchema(invalidSchema);
if (errors.length > 0) {
errors.forEach((err) => {
console.error(`${err.message} at line ${err.line}, column ${err.column}`);
});
}const { isValid, errors } = analyzeSpiceDbSchema(ast);
if (!isValid) {
errors.forEach((err) => {
console.error(`${err.code}: ${err.message}`);
});
}Common error types:
UNDEFINED_TYPE- Referenced type doesn't existCIRCULAR_DEPENDENCY- Circular permission dependenciesDUPLICATE_DEFINITION- Duplicate type namesUNDEFINED_RELATION- Referenced relation doesn't existINVALID_EXPRESSION- Malformed permission expression
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ .zed Schema │───▶│ Parser │───▶│ Semantic │───▶│ SDK │
│ │ │ (Chevrotain) │ │ Analyzer │ │ Generator │
└─────────────┘ └──────────────┘ └─────────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ AST │ │ Augmented AST │ │ Generated │
│ │ │ + Type Info │ │ permissions │
└──────────────┘ └─────────────────┘ │ + dynamic* │
└──────┬───────┘
│
Generated SDK (type-safe facade) ──────────┐ │
▼ ▼
Fluent Builder ─────────────────────▶ Operation<T> ──▶ SpiceDB (gRPC)
(string-based, dynamic) grant/revoke/
check/bulkCheck/
find/lookup/
delete/batch
Both the generated SDK and the raw fluent builder produce the same Operation<T> shape that ultimately drives the @authzed/authzed-node gRPC client. The generator just narrows the string parameters into literal types derived from your schema.
pnpm run buildpnpm testpnpm run lint
pnpm run lint:fix- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature - Make your changes and add tests
- Run the test suite:
pnpm test - Commit your changes:
git commit -m 'Add amazing feature' - Push to the branch:
git push origin feature/amazing-feature - Open a Pull Request
Open source under the MIT license
- SpiceDB - The authorization system this library supports
- @authzed/authzed-node - Official Node.js client for SpiceDB
- Chevrotain - Parser building toolkit used for
.zedparsing