Skip to content

Codegen

This page answers: how do I get TypeScript types out of my roles array so I can write resource: TArbacResource instead of resource: string? It documents the library API and the aoothjs-arbac-codegen CLI shipped by @aooth/arbac.

The output is a .ts file with three exports: a Resource union, an Action union, and a ResourceActionMap shape that maps each resource literal to the set of actions declared on it.

The output shape

ts
// Generated by aoothjs-arbac-codegen — do not edit.
export type Resource = "articles" | "comments" | "users";
export type Action = "create" | "delete" | "read" | "update";

export type ResourceActionMap = {
  articles: "create" | "read" | "update";
  comments: "delete" | "read";
  users: "create" | "read";
};

Three things to notice:

  • Resource is the union of every non-wildcard resource ID that appears across all roles' rules.
  • Action is the union of every non-wildcard action.
  • ResourceActionMap narrows actions per resourcearticles has its own action union, distinct from comments.

Use the map shape to type per-resource handlers:

ts
function handle<R extends Resource>(resource: R, action: ResourceActionMap[R]): void;

Library API

The library exposes two functions you can use inside your own scripts. Both are pure — no FS I/O.

extractResourceActions(roles, options?)

ts
function extractResourceActions(
  roles: TArbacRole<unknown, unknown>[],
  options?: { includeWildcards?: boolean },
): TResourceActionMap;

interface TResourceActionMap {
  resources: Map<string, Set<string>>; // resource → set of actions
  allResources: Set<string>; // unique resources
  allActions: Set<string>; // unique actions
}

Walks every role's rules and builds the resource→actions map. By default, entries containing * are skipped — wildcards are not literal types, so emitting them would be misleading.

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

const roles = [
  defineRole().id("reader").allow("articles", "read").allow("comments", "read").build(),
  defineRole().id("writer").allow("articles", "update").allow("articles", "*").build(),
];

extractResourceActions(roles);
// → {
//     resources:    Map { 'articles' => Set { 'read', 'update' }, 'comments' => Set { 'read' } },
//     allResources: Set { 'articles', 'comments' },
//     allActions:   Set { 'read', 'update' },
//   }

Pass includeWildcards: true if you genuinely want wildcard entries in the output — typically you don't.

generateResourceTypes(map, options?)

ts
function generateResourceTypes(map: TResourceActionMap, options?: TCodegenOptions): string;

Returns a .ts source string. No file is written — that's your job (or use the CLI).

TCodegenOptions

OptionDefaultEffect
resourceTypeName'Resource'Name of the Resource union export.
actionTypeName'Action'Name of the Action union export.
resourceActionMaptrueEmit the ResourceActionMap type. Set false to skip it.
header(default banner)String prepended to the output. Use it for license headers, eslint-disable lines, etc.
ts
import { extractResourceActions, generateResourceTypes } from "@aooth/arbac";

const map = extractResourceActions(roles);
const ts = generateResourceTypes(map, {
  resourceTypeName: "AppResource",
  actionTypeName: "AppAction",
  resourceActionMap: false,
  header: "/* eslint-disable */\n",
});

await fs.writeFile("./src/generated/arbac-types.ts", ts);

CLI: aoothjs-arbac-codegen

Registered via "bin". The CLI is a thin wrapper around the two library functions — it imports your roles, calls extractResourceActions + generateResourceTypes, and writes the result to disk.

bash
aoothjs-arbac-codegen --roles dist/roles.mjs --output src/generated/arbac-types.ts

Options

FlagRequiredNotes
--roles <path>YesPath to a JS module whose default export (or named roles export) is a TArbacRole[].
--output <path>YesDestination .ts file. Parent directory must exist.
--resource-type <name>NoOverride resourceTypeName (default 'Resource').
--action-type <name>NoOverride actionTypeName (default 'Action').
--export-name <name>NoAlias for --action-type.
--helpNoPrint help and exit.

--roles must be runnable JS

--roles must point at JavaScript, not TypeScript

The CLI is a Node ESM script that import()s the path you give it. Authoring roles in TypeScript is fine — but you must build them first and point the CLI at the built .mjs (or .cjs) artifact. Pointing --roles at a raw .ts file will fail with a module-resolution error.

Recommended split:

src/
  roles.ts              # author roles here
dist/
  roles.mjs             # built by tsc/tsdown/vp before codegen
src/generated/
  arbac-types.ts        # codegen output

Build-pipeline integration

The conventional wiring is a pretsc (or equivalent) script that runs the codegen before the project's main TypeScript build:

json
{
  "scripts": {
    "build:roles": "vp build --filter ./src/roles.ts",
    "codegen:arbac": "aoothjs-arbac-codegen --roles dist/roles.mjs --output src/generated/arbac-types.ts",
    "pretsc": "pnpm run build:roles && pnpm run codegen:arbac",
    "tsc": "tsc -p tsconfig.json"
  }
}

Two pieces in order: build the roles to JS, then run the CLI. Once arbac-types.ts exists, your application code can import it like any other file.

Check the generated file into git

The output is deterministic given the inputs, but checking it in means PR reviewers see role changes as type changes — which is the whole point of doing this in the first place.

Wildcard skipping

The default (includeWildcards: false) means a rule like allow('articles', '*') contributes the resource 'articles' to Resource but contributes no action to Action for that resource (since * is not a real action name).

Why default-skip:

  • Emitting '*' as a literal would let action: '*' typecheck in callers, defeating the point of Action being a closed union.
  • Wildcards in roles are typically blanket grants for admin-tier roles; codegen consumers (request handlers, audit code) want the enumerated universe, not the wildcard placeholder.

If you have a use case for keeping wildcards in the output, set includeWildcards: true on extractResourceActions directly. The CLI does not expose this flag — it always skips.

End-to-end example

ts
// src/roles.ts
import { defineRole, allowTableWrite } from "@aooth/arbac";

export const roles = [
  defineRole()
    .id("editor")
    .allow("articles", "read")
    .allow("articles", "update")
    .allow("comments", "moderate")
    .build(),

  defineRole()
    .id("admin")
    .allow("articles", "*") // skipped from Action union
    .allow("users", "create")
    .allow("users", "delete")
    .build(),
];

After pnpm run build:roles && pnpm run codegen:arbac:

ts
// src/generated/arbac-types.ts (excerpt)
export type Resource = "articles" | "comments" | "users";
export type Action = "create" | "delete" | "moderate" | "read" | "update";

export type ResourceActionMap = {
  articles: "read" | "update";
  comments: "moderate";
  users: "create" | "delete";
};

Note articles only lists 'read' | 'update' — the wildcard '*' from admin didn't contribute.

Gotchas

  • --roles must be JS. Build first. The CLI does not transpile.
  • Wildcards are skipped by default. Use includeWildcards: true on the library API if you need them. The CLI has no flag for it.
  • Output is sorted. Keys and union members come out alphabetically — diffs are stable across runs.
  • Roles must default-export the array (or be exported under the named roles export). The CLI checks default first, then roles; if neither is an array, it errors.
  • ResourceActionMap can be huge. Hundreds of resources × actions = a very wide type. If tsc slows down, set resourceActionMap: false and use just Resource / Action.

See also

  • Builder API — where most role definitions live.
  • Privilege Factories — the allowTable* helpers emit a lot of resources/actions; codegen rolls them up automatically.
  • Mental Model — the vocabulary the generated types are named after.

Released under the MIT License.