Skip to content

Builder API

This page answers: how do I write roles without hand-rolling TArbacRole object literals, and how do I keep the generics flowing from the role through to my scope functions? It documents defineRole() from @aooth/arbac.

The builder is the recommended way to declare roles. It pins generics once, preserves rule order, and lets you splice in reusable privilege factories with .use(...).

Signature

ts
function defineRole<
  TUserAttrs extends object = object,
  TScope extends object = object,
>(): RoleBuilder<TUserAttrs, TScope>;

Two generic parameters, both defaulting to object. They flow into every chain method so your scope callbacks are typed without manual annotations.

The chain

MethodEffectRequired
.id(value)Sets the role ID. Last call wins.Yes.build() throws without it.
.name(value)Optional display name. Last call wins.No
.describe(value)Optional description. Last call wins.No
.allow(resource, action, scope?)Pushes { resource, action, scope? }. The scope key is omitted from the emitted rule when not provided.
.deny(resource, action)Pushes { resource, action, effect: 'deny' }. No scope argument — deny rules cannot carry a scope.
.use(...privileges)Invokes each TPrivilegeFunction and splices its rules into the current list in place. Variadic-tuple typed — each privilege keeps its own scope shape (see Generic pinning below).
.build()Returns a plain TArbacRole<TUserAttrs, TScope> with a copy of the rules array.

.id() is required

Calling .build() without an ID throws "Role id is required. Call .id() before .build().". There is no default ID.

Quick example

ts
import { defineRole } from "@aooth/arbac";

type Attrs = { dept: string };
type Scope = { dept: string };

const editor = defineRole<Attrs, Scope>()
  .id("editor")
  .name("Editor")
  .describe("Can read articles globally, update only their department, never publish.")
  .allow("articles", "read")
  .allow("articles", "update", (a) => ({ dept: a.dept }))
  .deny("articles", "publish")
  .build();

The emitted editor is a plain object — pass it to arbac.registerRole(editor).

Generic pinning

Pin the generics on defineRole<Attrs, Scope>() once. Every subsequent .allow() / .deny() / .use() infers from them.

ts
type Attrs = { dept: string };
type Scope = { dept: string };

const role = defineRole<Attrs, Scope>()
  .id("editor")
  .allow("articles", "update", (a) => ({
    // ^ TS infers a: Attrs
    dept: a.dept, // returning Scope ✓
  }))
  .build();

If you skip the generics, both default to object and you'll get unknown inside the scope callback. Pin them every time.

Role-level TScope is upper-bound documentation, not enforcement

.use() accepts a mix of typed-scope privileges in a single call — each privilege keeps its own scope shape. This matters for typed-table privileges like allowTableRead<Attrs, ArbacDbScope<Task>>(...) and allowTableRead<Attrs, ArbacDbScope<Comment>>(...): the two scope shapes are not structurally assignable to each other, but you can drop both into one .use(...) call. The role-level TScope pin (defineRole<Attrs, ArbacDbScope>()) stays as upper-bound documentation of what scope objects look like at evaluate time. See @aooth/arbac API reference for the full .use() signature.

Rule order preservation

The builder pushes rules in call order. Both .deny() and .allow() for the same (resource, action) end up in the emitted role — and the engine applies its own deny-wins semantics regardless of order.

ts
defineRole().id("x").allow("articles", "read").deny("articles", "read").build();
// rules: [
//   { resource: 'articles', action: 'read' },
//   { resource: 'articles', action: 'read', effect: 'deny' },
// ]
// → engine evaluates as denied (deny wins)

Order matters for one thing only: debugging. Rule arrays end up in audit logs and codegen output in declared order, so write the most important rules first.

Splicing in privilege factories with .use()

.use(...privileges) accepts any number of privileges, each typed as TPrivilegeFunction<TUserAttrs, TScopes[K]> (variadic tuple). Each is called immediately and its returned rules are spliced into the current rule list.

ts
import { defineRole, definePrivilege, allowTableWrite } from "@aooth/arbac";

type Attrs = { dept: string };
type Scope = { dept: string };

// A small factory.
const canModerate = definePrivilege<Attrs, Scope>()(() => [
  { resource: "comments", action: "flag" },
  { resource: "comments", action: "hide" },
]);

const manager = defineRole<Attrs, Scope>()
  .id("manager")
  .use(allowTableWrite("articles", { scope: (a) => ({ dept: a.dept }) }))
  .use(canModerate())
  .deny("articles", "publish")
  .build();

.use() is a splice, not a delegate — by the time .build() runs the role is a flat TArbacRole with all rules inlined. Privilege factories cannot reach into the builder to set metadata; they only contribute rules.

See Privilege Factories for what to put in a privilege function, including definePrivilege and the allowTable* family.

When to omit the scope argument

Two equivalent ways to grant unrestricted read on articles:

ts
defineRole().id("admin").allow("articles", "read").build();
defineRole()
  .id("admin")
  .allow("articles", "read", () => ({}))
  .build();

Both result in the engine pushing {} (the universe sentinel) into the scopes array when the rule matches. The first form is preferred — it leaves the scope key absent on the emitted rule, which is what tooling (audit logs, codegen, etc.) usually expects.

Don't write scope: () => ({}) "for symmetry"

The omitted form is slightly more efficient (no scope-fn call at eval time) and more honest about intent.

.deny() has no scope argument

.deny() accepts only (resource, action) — there is no third parameter. The type system reflects this: TArbacRule forbids scope alongside effect: 'deny'. If you find yourself wanting a "conditional deny", express it as a wildcard allow plus a tighter exception, or compute the condition inside a scope fn that returns no matching rows.

End-to-end

ts
import { Arbac, defineRole, allowTableWrite } from "@aooth/arbac";

type Attrs = { dept: string; assignment: string[] };
type Scope = { dept: string };

const manager = defineRole<Attrs, Scope>()
  .id("manager")
  .name("Manager")
  .describe("Departmental manager.")
  .use(allowTableWrite("articles", { scope: (a) => ({ dept: a.dept }) }))
  .deny("articles", "publish")
  .allow("comments", "moderate")
  .build();

const arbac = new Arbac<Attrs, Scope>();
arbac.registerRole(manager);

await arbac.evaluate(
  { resource: "articles", action: "update" },
  { id: "u1", roles: ["manager"], attrs: { dept: "sales", assignment: [] } },
);
// → { allowed: true, scopes: [{ dept: 'sales' }] }

await arbac.evaluate(
  { resource: "articles", action: "publish" },
  { id: "u1", roles: ["manager"], attrs: { dept: "sales", assignment: [] } },
);
// → { allowed: false }

Gotchas

  • .build() throws without .id(). Always set the ID first, or last — but set it.
  • .use() rules are inlined. Don't expect to introspect "which privilege did this rule come from" at runtime. If you need that, name your rules with a resource-prefix convention.
  • No effect: 'allow' literal. .allow() omits effect entirely; the engine treats absence as allow. The type system forbids the explicit literal.
  • No deduplication. Calling the same .allow() twice emits two rules. Cheap at eval time, noisy in audits — write each rule once.

Next

  • Privilege Factories — how to write reusable rule bundles that plug into .use().
  • Codegen — generate Resource / Action TypeScript types from a roles array.

Released under the MIT License.