Typed vs untyped
An honest side-by-side of createIam and the bare builders - what each catches, what neither catches, and how to move between them
duck-iam exports the same builders twice: as bare factories (defineRole, definePolicy, defineRule, when) and as methods on the object createIam() returns. The runtime is identical - the same classes, the same output shapes, the same evaluation. The only difference is how much of your permission vocabulary TypeScript knows about.
At a glance
| Concern | Untyped builders | createIam() |
|---|---|---|
Action typo ('raed' for 'read') | Silent runtime deny | Compile error |
| Unknown resource type | Silent runtime deny | Compile error |
Misspelled role ID in defineRole / inherits | DANGLING_INHERIT at validation time, if you validate | Compile error |
| Wrong scope string | Silent runtime miss | Compile error when scopes is declared |
| Wrong field path in a condition | Resolves to null; comparison quietly wrong | Compile error, but only with a closed context |
| Wrong value type for a field | Operator returns false | Compile error, but only with a closed context |
engine.can() argument checking | None | Constrained to the declared unions |
engine.permissions() batch checking | None | Constrained through access.checks() |
Wrong $-reference path | Resolves to null | Autocompleted, but not cross-validated against the field type |
| Setup cost | One import | One config object plus as const on each array |
| Runtime cost | None | None - all of it is erased |
| Bundle cost | None | createIam retains the validator chunk (see below) |
Where each error surfaces
The right-hand path is the reason the typed config exists. A misspelled action does not throw and does not log; the rule never matches, the request is denied by defaultEffect, and the bug looks like a permissions-modelling problem until someone diffs the strings.
What each catches
Both
- Corrupt stored rows. On the read path, an adapter that cannot parse a row runs
validatePolicy/validateRoleon it to name what is wrong: file, redis, http, drizzle and prisma all do this. A policy row that will not parse throws; a role row is skipped after a warning. Nothing validates on the way in -savePolicyandsaveRolewrite what you give them. - Role-graph problems via
validateRoles(): duplicate IDs, danglinginherits, over-deep chains. - Structural problems in externally sourced policies via
validatePolicy(): bad algorithm, malformed condition group, unknown operator, unresolvable field root, catastrophic regex. - Every runtime guard rail in the engine -
MAX_CONDITION_DEPTH,MAX_INHERITANCE_DEPTH, the regex caps, the adapter timeout.
Only the typed config
- Action, resource, role, and scope typos, at the call site, as you type them.
- Attribute keys in
attr(),resourceAttr(), andenv()- once acontextis declared. - Value types compared against a field -
env('hour', 'lt', 'noon')is an error whenhourisnumber. - Per-resource attribute keys under
.of()andgrantWhen(). - Refactors: rename a role in the config and TypeScript flags every reference.
Neither
- Logic bugs. Nothing tells you a rule should have been
denyrather thanallow, or that a priority is inverted. - Cross-validation of a
<MathMl mathml="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi mathvariant="normal">‘</mi><mo>−</mo><mi>r</mi><mi>e</mi><mi>f</mi><mi>e</mi><mi>r</mi><mi>e</mi><mi>n</mi><mi>c</mi><mi>e</mi><mi>a</mi><mi>g</mi><mi>a</mi><mi>i</mi><mi>n</mi><mi>s</mi><mi>t</mi><mi>t</mi><mi>h</mi><mi>e</mi><mi>f</mi><mi>i</mi><mi>e</mi><mi>l</mi><mi>d</mi><mi>i</mi><mi>t</mi><mi>i</mi><mi>s</mi><mi>c</mi><mi>o</mi><mi>m</mi><mi>p</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi>t</mi><mi>o</mi><mi mathvariant="normal">.</mi><mi>T</mi><mi>h</mi><mi>e</mi><mi>v</mi><mi>a</mi><mi>l</mi><mi>u</mi><mi>e</mi><mi>t</mi><mi>y</mi><mi>p</mi><mi>e</mi><mi>i</mi><mi>s</mi><mi>a</mi><mi>u</mi><mi>n</mi><mi>i</mi><mi>o</mi><mi>n</mi><mi>o</mi><mi>f</mi><mi>t</mi><mi>h</mi><mi>e</mi><mi>f</mi><mi>i</mi><mi>e</mi><mi>l</mi><msup><mi>d</mi><mo mathvariant="normal" lspace="0em" rspace="0em">′</mo></msup><mi>s</mi><mi>t</mi><mi>y</mi><mi>p</mi><mi>e</mi><mi>a</mi><mi>n</mi><mi>d</mi><mi>e</mi><mi>v</mi><mi>e</mi><mi>r</mi><mi>y</mi><mi mathvariant="normal">‘</mi></mrow><annotation encoding="application/x-tex">-reference against the field it is compared to. The value type is a union of the field's type and every</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7778em;vertical-align:-0.0833em;"></span><span class="mord">‘</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.9463em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">re</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">ere</span><span class="mord mathnormal">n</span><span class="mord mathnormal">ce</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">ain</span><span class="mord mathnormal">s</span><span class="mord mathnormal">tt</span><span class="mord mathnormal">h</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">i</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">d</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal">i</span><span class="mord mathnormal">sco</span><span class="mord mathnormal">m</span><span class="mord mathnormal">p</span><span class="mord mathnormal">a</span><span class="mord mathnormal">re</span><span class="mord mathnormal">d</span><span class="mord mathnormal">t</span><span class="mord mathnormal">o</span><span class="mord">.</span><span class="mord mathnormal" style="margin-right:0.13889em;">T</span><span class="mord mathnormal">h</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.03588em;">v</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">u</span><span class="mord mathnormal">e</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.03588em;">y</span><span class="mord mathnormal">p</span><span class="mord mathnormal">e</span><span class="mord mathnormal">i</span><span class="mord mathnormal">s</span><span class="mord mathnormal">a</span><span class="mord mathnormal">u</span><span class="mord mathnormal">ni</span><span class="mord mathnormal">o</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">i</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord"><span class="mord mathnormal">d</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.7519em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span></span></span></span></span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.03588em;">y</span><span class="mord mathnormal">p</span><span class="mord mathnormal">e</span><span class="mord mathnormal">an</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.03588em;">v</span><span class="mord mathnormal" style="margin-right:0.03588em;">ery</span><span class="mord">‘</span></span></span></span>"/>-path, so mismatched pairs compile. - Circular role inheritance as a failure.
validateRoles()reportsCIRCULAR_INHERITas a warning and leavesvalidattrue. - Stale cached decisions, concurrent attribute writes, or permissions modelled at the wrong granularity.
For the first item, write tests. For caching, see engine caching.
The two styles side by side
Untyped
import { defineRole, definePolicy, IamEngine } from '@gentleduck/iam'
const viewer = defineRole('viewer')
.grant('raed', 'post') // typo - no error
.build()
const restrictPolicy = definePolicy('restrict')
.rule('block', (r) =>
r
.deny()
.on('approval') // a typo, or a real custom action? nothing can tell
.of('budget')
.when((w) => w.attr('departmnt', 'eq', 'eng')), // typo - no error
)
.build()
const engine = new IamEngine({ adapter })
await engine.can('user-1', 'raed', { type: 'post', attributes: {} })
// false, because no role grants 'raed'
Good for: prototypes, migration scripts, and library code that must stay generic over an unknown vocabulary. Bad for: anything a team maintains, because the strings become the contract and nothing enforces it.
Typed
import { createIam } from '@gentleduck/iam'
const access = createIam({
actions: ['create', 'read', 'update', 'delete'] as const,
resources: ['post', 'comment'] as const,
roles: ['viewer', 'editor'] as const,
})
const viewer = access
.defineRole('viewer')
.grant('raed', 'post') // error: '"raed"' is not assignable to '"create" | "read" | "update" | "delete"'
.build()
Good for: production applications. Costs: one config object, as const on each array, and TypeScript errors that get long when the generics nest - though they name the offending literal first.
Choosing
The context is separable from the rest. Declaring actions, resources, roles, and scopes is cheap and pays immediately; declaring a closed context is a bigger commitment because every attribute your policies touch has to be in it. Starting with the first and adding the second later is a normal path, and nothing about the stored policies changes when you do.
Migrating
The role and policy data shape is identical, so adapters, stored rows, serialisation, and evaluation all keep working. Only the builder entry points move.
Before:
import { defineRole, IamEngine } from '@gentleduck/iam'
const viewer = defineRole('viewer').grant('read', 'post').build()
const engine = new IamEngine({ adapter })
After:
import { createIam } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
const access = createIam({
actions: ['read'] as const,
resources: ['post'] as const,
roles: ['viewer'] as const,
})
const viewer = access.defineRole('viewer').grant('read', 'post').build()
const adapter = new IamMemoryAdapter<'read', 'post', 'viewer', string>({
roles: [viewer],
assignments: { 'user-1': ['viewer'] },
})
const engine = access.createEngine({ adapter })
Expect the first compile after the switch to surface real typos. Widen the vocabulary arrays only after checking each one - an error that says an action is not in the union is usually right.
Mixing the two
Static policies typed, dynamic policies untyped and validated, both handed to the same engine:
import { createIam } from '@gentleduck/iam'
const access = createIam({
actions: ['read', 'update'] as const,
resources: ['post'] as const,
})
const engine = access.createEngine({ adapter })
const myPolicy = access
.definePolicy('app-owned')
.name('App Owned')
.algorithm('deny-overrides')
.rule('owner-update', (r) => r.allow().on('update').of('post').when((w) => w.isOwner()))
.build()
const raw: unknown = await loadPolicyFromAdminUi()
const check = access.validatePolicy(raw)
if (!check.valid) throw new Error(check.issues.map((i) => i.message).join('; '))
await engine.admin.savePolicy(myPolicy)
The typed and untyped builders produce the same AccessControl.IPolicy object, so the engine cannot tell them apart. validatePolicy() is the boundary that has to hold for the untyped half. Reaching it without createIam means importing from @gentleduck/iam/core/validate; the root entry point does not re-export the validators.
Gotchas
createIamis not free in bundle terms. The root entry point withholds the validator functions so the roughly 12 KB chunk stays opt-in, butcreateIamcloses overvalidatePolicyandvalidateRolesto expose them as methods. A browser bundle that callscreateIamcarries the validator whether or not it validates anything.as constis easy to forget and silent when missed. An array that widened tostring[]produces a config where everything compiles - the same code you had before the migration, minus the checks. Grep forcreateIam(and confirm each array.- Typed does not mean validated. Nothing about
createIamruns at runtime. Data crossing a trust boundary still needsvalidatePolicy().
See also
- Type-safe config overview - the reading order for the rest of this section.
- createIam() - options and inference in detail.
- Validation - what the runtime checks actually cover.
- Type-safe roles - the same argument applied to the role builder.