Skip to main content

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.

OptionTypeRequiredDefaultWhat it constrains
actionsTActions extends readonly string[]yesnonegrant(action, ...), grantWhen(action, ...), rule.on(...), engine.can(_, action, ...), entries in checks()
resourcesTResources extends readonly string[]yesnonegrant(_, resource), rule.of(...), when().resourceType(...), the type field of a resource passed to the engine
scopesTScopes 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
rolesTRoles 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
contextTContext extends objectnoDotPath.IDefaultContextField paths and value types on when().check/eq/neq/in/attr/resourceAttr/env - see typed context

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.

Loading diagram...

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.

MemberTypeNotes
actionsreadonly TAction[]The array you passed, by reference
resourcesreadonly TResource[]The array you passed, by reference
scopesreadonly TScope[]input.scopes ?? []
rolesreadonly 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, typedIdentity at runtime
validateRoles(roles)IamValidate.IResultTakes the unconstrained IRole[]; forwards the declared vocabulary alongside it
validatePolicy(input)IamValidate.IResultTakes unknown; forwards to the standalone validatePolicy

Full signatures and examples are on the methods reference.

Optional-field behaviour

Loading diagram...

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

  • defineRule has a different generic order. It is RuleBuilder<TAction, TResource, TScope, TRole, TContext> while defineRole, definePolicy, and when are <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.
  • validateRoles takes the unconstrained IRole, 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).
  • createEngine does not infer TMode from config.mode. mode is optional, so the type argument and the config field move independently. Pass both or neither.
  • createIam pulls the validator into your bundle. core/index.ts deliberately does not re-export the validator functions so the roughly 12 KB chunk stays opt-in, but config.ts imports validatePolicy and validateRoles directly and the returned object closes over both. If you use createIam, that code is reachable whether or not you call the validate methods.
  • as const on the outer object does nothing useful. The const type parameters already capture the tuples. Annotating each array is what matters.

See also