createIam()
The typed-config factory - every option with its type and default, how the five generics are inferred, and the exact shape of the returned object
createIam() is the only function in duck-iam that takes your permission vocabulary and returns builders bound to it. It is a pure factory: it allocates one object literal, stores four arrays, and closes over the builder constructors. This page documents its input, its generics, and its return value against src/core/config/config.ts and src/core/config/config.types.ts.
Signature
export 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
>
import { createIam } from '@gentleduck/iam'
const access = createIam({
actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
resources: ['post', 'comment', 'user', 'dashboard'] as const,
scopes: ['org-1', 'org-2'] as const,
roles: ['viewer', 'editor', 'admin', 'super-admin'] as const,
})
Options
Every field of IamConfig.IAccessConfigInput.
| Option | Type | Required | Default | What it constrains |
|---|---|---|---|---|
actions | TActions extends readonly string[] | yes | none | grant(action, ...), grantWhen(action, ...), rule.on(...), engine.can(_, action, ...), entries in checks() |
resources | TResources extends readonly string[] | yes | none | grant(_, resource), rule.of(...), when().resourceType(...), the type field of a resource passed to the engine |
scopes | TScopes extends readonly string[] | no | [] on the returned object; the type parameter defaults to readonly string[] | The third argument of grant, role.scope(...), when().scope(...) / when().scopes(...), the scope argument of engine checks |
roles | TRoles extends readonly string[] | no | [] on the returned object; the type parameter defaults to readonly string[] | defineRole(id), inherits(...), when().role(...) / when().roles(...), policy.targets.roles |
context | TContext extends object | no | DotPath.IDefaultContext | Field paths and value types on when().check/eq/neq/in/attr/resourceAttr/env - see typed context |
context is read by nothing. It exists so TypeScript can capture TContext from an argument position. Give it a value that costs nothing at runtime, such as {} as unknown as AppContext; the object is discarded and only the type survives.How the generics resolve
The four vocabulary parameters are declared const, so TypeScript preserves the literal tuple at the call site and the factory indexes it with [number] to get a union.
The two branches are the whole story. Once an array has widened to string[] at its declaration site, nothing later in the pipeline can recover the literals, and every builder silently accepts any string. If you want the arrays in a separate module, annotate them there:
// vocabulary.ts
export const ACTIONS = ['create', 'read', 'update', 'delete'] as const
export const RESOURCES = ['post', 'comment'] as const
// access.ts
import { createIam } from '@gentleduck/iam'
import { ACTIONS, RESOURCES } from './vocabulary'
export const access = createIam({ actions: ACTIONS, resources: RESOURCES })
What you get back
IamConfig.IAccessConfig has four readonly properties and eight methods.
| Member | Type | Notes |
|---|---|---|
actions | readonly TAction[] | The array you passed, by reference |
resources | readonly TResource[] | The array you passed, by reference |
scopes | readonly TScope[] | input.scopes ?? [] |
roles | readonly TRole[] | input.roles ?? [] |
defineRole(id) | RoleBuilder<TAction, TResource, TRole, TScope, TContext> | id constrained to TRole |
definePolicy(id) | PolicyBuilder<TAction, TResource, TRole, TScope, TContext> | id is a free string |
defineRule(id) | RuleBuilder<TAction, TResource, TScope, TRole, TContext> | id is a free string; note the swapped scope/role parameter order |
when() | When<TAction, TResource, TRole, TScope, TContext> | Fresh builder per call |
createEngine(config) | IamEngine<TAction, TResource, TRole, TScope, TMode> | TMode defaults to 'production', and is not inferred from config.mode |
checks(arr) | the same array, typed | Identity at runtime |
validateRoles(roles) | IamValidate.IResult | Takes the unconstrained IRole[]; forwards the declared vocabulary alongside it |
validatePolicy(input) | IamValidate.IResult | Takes unknown; forwards to the standalone validatePolicy |
Full signatures and examples are on the methods reference.
Optional-field behaviour
The two branches per option are independent, which the scopes/roles default to [] independently of one another test pins directly. Concretely:
const access = createIam({
actions: ['read', 'write'] as const,
resources: ['post'] as const,
})
access.defineRole('anything-at-all') // compiles: TRole is string
access.defineRole('viewer').grant('read', 'post', 'any-scope') // compiles: TScope is string
access.scopes // []
access.roles // []
Declaring roles flips defineRole, inherits, when().role(), and when().roles() to the declared union. Declaring scopes flips the scope argument of grant, role.scope(), when().scope(), and when().scopes(). Declaring context is what turns on dot-path autocomplete; without it the builders accept any string field path and IamPrimitives.AttributeValue for values, and runtime behaviour is unchanged.
Deriving the unions elsewhere
The config exposes its vocabulary as readonly arrays, so you can index them for the union type instead of restating it:
export const access = createIam({
actions: ['create', 'read', 'update', 'delete'] as const,
resources: ['post', 'comment'] as const,
roles: ['viewer', 'editor'] as const,
})
export type AppAction = (typeof access.actions)[number] // 'create' | 'read' | 'update' | 'delete'
export type AppResource = (typeof access.resources)[number] // 'post' | 'comment'
export type AppRole = (typeof access.roles)[number] // 'viewer' | 'editor'
Those aliases are what you pass to IamMemoryAdapter, IamDrizzleAdapter, or any server wrapper so the adapter's generics line up with the config's.
Gotchas
defineRulehas a different generic order. It isRuleBuilder<TAction, TResource, TScope, TRole, TContext>whiledefineRole,definePolicy, andwhenare<TAction, TResource, TRole, TScope, TContext>. This only bites when you write an explicit type annotation for a rule builder; the factory wires it correctly for you.validateRolestakes the unconstrainedIRole, on purpose. A runtime validator exists for data whose type you do not trust, and a signature narrowed to the declared unions could only accept values already proven correct. Roles read from a database go through this method directly - and this one, unlike the bare export, also flags a grant naming vocabulary the config never declared (UNREACHABLE_TARGET).createEnginedoes not inferTModefromconfig.mode.modeis optional, so the type argument and the config field move independently. Pass both or neither.createIampulls the validator into your bundle.core/index.tsdeliberately does not re-export the validator functions so the roughly 12 KB chunk stays opt-in, butconfig.tsimportsvalidatePolicyandvalidateRolesdirectly and the returned object closes over both. If you usecreateIam, that code is reachable whether or not you call the validate methods.as conston the outer object does nothing useful. Theconsttype parameters already capture the tuples. Annotating each array is what matters.
See also
- Methods reference - each returned method with its signature and behaviour.
- Typed context - what the
contextoption unlocks. - Typed vs untyped - whether the setup is worth it for your project.
- Types and namespaces -
IamConfig.IAccessConfigInputandIamConfig.IAccessConfigin the wider type map.