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
// 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:
Resourceis the union of every non-wildcard resource ID that appears across all roles' rules.Actionis the union of every non-wildcard action.ResourceActionMapnarrows actions per resource —articleshas its own action union, distinct fromcomments.
Use the map shape to type per-resource handlers:
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?)
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.
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?)
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
| Option | Default | Effect |
|---|---|---|
resourceTypeName | 'Resource' | Name of the Resource union export. |
actionTypeName | 'Action' | Name of the Action union export. |
resourceActionMap | true | Emit 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. |
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.
aoothjs-arbac-codegen --roles dist/roles.mjs --output src/generated/arbac-types.tsOptions
| Flag | Required | Notes |
|---|---|---|
--roles <path> | Yes | Path to a JS module whose default export (or named roles export) is a TArbacRole[]. |
--output <path> | Yes | Destination .ts file. Parent directory must exist. |
--resource-type <name> | No | Override resourceTypeName (default 'Resource'). |
--action-type <name> | No | Override actionTypeName (default 'Action'). |
--export-name <name> | No | Alias for --action-type. |
--help | No | Print 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 outputBuild-pipeline integration
The conventional wiring is a pretsc (or equivalent) script that runs the codegen before the project's main TypeScript build:
{
"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 letaction: '*'typecheck in callers, defeating the point ofActionbeing 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
// 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:
// 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
--rolesmust be JS. Build first. The CLI does not transpile.- Wildcards are skipped by default. Use
includeWildcards: trueon 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
rolesexport). The CLI checksdefaultfirst, thenroles; if neither is an array, it errors. ResourceActionMapcan be huge. Hundreds of resources × actions = a very wide type. Iftscslows down, setresourceActionMap: falseand use justResource/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.