Type-safe roles
createIam() constrains role IDs, actions, resources, and scopes at compile time - how the type parameters reach RoleBuilder and where the guarantee stops
defineRole() imported from the package root accepts any string for every argument. createIam() fixes the action, resource, role, scope, and context types once, and hands back builders that reject anything outside them. This page shows what createIam() constrains, how the generics reach RoleBuilder, and the four places the compile-time guarantee does not hold.
createIam()
import { createIam } from '@gentleduck/iam'
const access = createIam({
actions: ['create', 'read', 'update', 'delete', 'publish'] as const,
resources: ['post', 'comment', 'user'] as const,
scopes: ['org-1', 'org-2'] as const,
roles: ['viewer', 'editor', 'admin'] as const,
})
function createIam<
const TActions extends readonly string[],
const TResources extends readonly string[],
const TRoles extends readonly string[] = readonly string[],
const TScopes extends readonly string[] = readonly string[],
TContext extends object = DotPath.IDefaultContext,
>(
input: IamConfig.IAccessConfigInput<TActions, TResources, TRoles, TScopes, TContext>,
): IamConfig.IAccessConfig<TActions[number], TResources[number], TRoles[number], TScopes[number], TContext>
| Input option | Type | Default | Meaning |
|---|---|---|---|
actions | readonly string[] | required | Every action your app supports. Becomes TAction. |
resources | readonly string[] | required | Every resource type. Becomes TResource. |
scopes | readonly string[] | [] | Tenant / workspace identifiers. Becomes TScope. |
roles | readonly string[] | [] | Role IDs. Becomes TRole. |
context | object | DotPath.IDefaultContext | Phantom field for dot-path inference. The runtime value is never read - pass {} as unknown as AppContext. |
as const is what makes this work. Without it TypeScript widens the array to string[], TActions[number] collapses to string, and every constraint silently disappears. There is no runtime error - the builders just stop rejecting typos.
The returned IamConfig.IAccessConfig carries the declared arrays back (access.actions, access.resources, access.scopes, access.roles - the last two are [] when not declared) plus eight factories: defineRole, definePolicy, defineRule, when, createEngine, checks, validateRoles, and validatePolicy.
How the generics reach RoleBuilder
IDXis the whole trick: a tuple declaredas constindexed bynumberbecomes the union of its members.RBis where the order changes.createIambuildsRoleBuilder<TAction, TResource, TRole, TScope, TContext>, but the standalone factory declares its own parameters asdefineRole<TRole, TAction, TResource, TScope, TContext>so thatTRolecan be inferred from the argument. If you ever write the type arguments by hand, check which of the two you are calling.GWthreads the resource literalRinto theWhenbuilder asTActiveResource, which is what powers per-resource attribute narrowing below.RLis whyw.role('admin')is checked:When.role()takesTRole, notstring.
What is constrained
const viewer = access
.defineRole('viewer') // ok: 'viewer' is in roles
.name('Viewer')
.grant('read', 'post') // ok
.grant('read', 'comment') // ok
.build()
// access.defineRole('intern') // error: not assignable to 'viewer' | 'editor' | 'admin'
// access.defineRole('viewer').grant('fly', 'post') // error: 'fly' is not an action
// access.defineRole('viewer').grant('read', 'invoice') // error: 'invoice' is not a resource
// access.defineRole('viewer').grant('read', 'post', 'org-9') // error: 'org-9' is not a scope
The same unions reach every other builder from the same access object, so a role ID used in a policy target or a condition is checked too:
const ownerPolicy = access
.definePolicy('owner-only')
.algorithm('deny-overrides')
.rule('admin-override', (r) =>
r
.allow()
.on('*')
.of('*')
.when((w) => w.role('admin')), // ok: 'admin' is in roles
// .when((w) => w.role('manager')) // error: not a declared role
)
.build()
Where the guarantee stops
inherits()accepts any string. Its parameter is(TRole | (string & {}))[], deliberately, so a role can inherit an ID that only exists in the database. Typos ininherits()are found byvalidateRoles()asDANGLING_INHERIT, not by the compiler.IRole.scopeis typedTScopewhileIPermission.scopeisTScope | '*'. Both read'*'as global at runtime, so under a narrowed scope uniongrant('read', 'post', '*')type-checks and.scope('*')does not. Leave.scope()off for a global role; omitting it is the same thing at runtime.- Data loaded from an adapter is unchecked. Types describe what you wrote, not what a database returned. Use
parseRoleRow()from@gentleduck/iam/core/validateon untrusted rows. - An open attribute bag re-opens the path types. With the default context,
check(),attr(),resourceAttr(), andenv()accept any string, becauseDotPath.FlexibleDotPathsfalls back tostringwhenever the context contains an index signature.
grantRead() and grantCRUD() used to be a fifth hole - they cast straight past TAction, so a config declaring actions: ['view', 'edit'] still compiled a read grant that no request could match. Their parameter types are now conditional on the action union and collapse to never, so the mismatch is a compile error at the call site. Export IAM_CRUD_ACTIONS and spell the list as [...IAM_CRUD_ACTIONS, 'publish'] to keep grantCRUD callable.
Typed dot-paths
Path types come from src/core/types/dot-path.ts. DotPath.DotPaths<T> walks an object type and produces the union of every reachable path; DotPath.FlexibleDotPaths<T> is that union, widened with string when T contains an open index signature.
DotPath.IDefaultContext declares subject.attributes, resource.attributes, and environment as IAnyAttributes, which is an index signature - so the default is the OPEN branch. Declaring those bags concretely moves you to CLOSED, where a mistyped path fails to compile and the operand value is checked against the type at that path. $-references are typed the same way through DotPath.DollarPaths<TContext>; see $-variable references and typed context.
Per-resource attribute narrowing
Declare a resourceAttributes map on the context and grantWhen()'s third argument narrows w.resourceAttr() to the attributes of the resource you named:
const access = createIam({
actions: ['read', 'update'] as const,
resources: ['post', 'invoice'] as const,
context: {} as unknown as {
subject: { id: string; attributes: { tier: 'free' | 'pro' } }
resourceAttributes: {
post: { ownerId: string; status: 'draft' | 'published' }
invoice: { customerId: string; amount: number }
}
},
})
const editor = access
.defineRole('editor')
.grantWhen('update', 'post', (w) => w.resourceAttr('status', 'eq', 'draft'))
// suggestions: 'ownerId' | 'status'
.grantWhen('update', 'invoice', (w) => w.resourceAttr('amount', 'lt', 1000))
// suggestions: 'customerId' | 'amount'
.build()
The mechanism is DotPath.ResolvedResourceAttrPaths<TContext, TActiveResource>: grantWhen<R> captures the resource literal as R and passes it to When as TActiveResource, which looks R up in the context's resourceAttributes map. '*' and any resource with no entry in the map fall back to the generic resource.attributes shape. The rule builder does the same through .of(); see rules.
Validating a role set
The compiler cannot see relationships between roles. Run the validator over the whole set before persisting:
const roles = [viewer, author, editor, admin]
const result = access.validateRoles(roles)
if (!result.valid) {
for (const issue of result.issues) {
if (issue.type === 'error') console.error(`${issue.code} on ${issue.roleId}: ${issue.message}`)
}
throw new Error('role set rejected')
}
access.validateRoles() is validateRoles() with the argument typed to your unions. Outside a createIam() setup, import it directly - it is not re-exported from the package root, because the validator is a separate chunk:
import { validateRoles } from '@gentleduck/iam/core/validate'
The five codes it can emit are tabulated on role inheritance. RoleBuilder.build() already runs the single-role validateRole() for you; validateRoles() adds everything that needs the whole set.
Complete example
import { createIam } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
const access = createIam({
actions: ['create', 'read', 'update', 'delete', 'publish', 'archive'] as const,
resources: ['post', 'comment', 'user', 'settings'] as const,
scopes: ['org-alpha', 'org-beta'] as const,
roles: ['viewer', 'author', 'editor', 'org-admin', 'super-admin'] as const,
})
const viewer = access.defineRole('viewer').name('Viewer').grantRead('post', 'comment').build()
const author = access
.defineRole('author')
.name('Author')
.inherits('viewer')
.grant('create', 'post')
.grantWhen('update', 'post', (w) => w.isOwner())
.grantWhen('delete', 'post', (w) => w.isOwner())
.grant('create', 'comment')
.build()
const editor = access
.defineRole('editor')
.name('Editor')
.inherits('author')
.grant('update', 'post')
.grant('delete', 'post')
.grant('publish', 'post')
.grant('archive', 'post')
.build()
const orgAdmin = access.defineRole('org-admin').name('Organization Admin').inherits('editor').build()
const superAdmin = access.defineRole('super-admin').name('Super Admin').grantAll('*').build()
const roles = [viewer, author, editor, orgAdmin, superAdmin]
if (!access.validateRoles(roles).valid) throw new Error('role set rejected')
const engine = access.createEngine({ adapter: new IamMemoryAdapter(), mode: 'development' })
for (const role of roles) await engine.admin.saveRole(role)
access.createEngine() returns an IamEngine whose can(), authorize(), and permissions() arguments are constrained to the same unions, so a check for an action you never declared is a compile error too. See engine methods.
See also
- Defining roles - the untyped builder these constraints wrap
- Typed context - declaring the context shape in full
- Access config - every member of the object
createIam()returns - Types and namespaces -
AccessControl,DotPath,IamConfig,IamValidate