Skip to main content

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

ConcernUntyped builderscreateIam()
Action typo ('raed' for 'read')Silent runtime denyCompile error
Unknown resource typeSilent runtime denyCompile error
Misspelled role ID in defineRole / inheritsDANGLING_INHERIT at validation time, if you validateCompile error
Wrong scope stringSilent runtime missCompile error when scopes is declared
Wrong field path in a conditionResolves to null; comparison quietly wrongCompile error, but only with a closed context
Wrong value type for a fieldOperator returns falseCompile error, but only with a closed context
engine.can() argument checkingNoneConstrained to the declared unions
engine.permissions() batch checkingNoneConstrained through access.checks()
Wrong $-reference pathResolves to nullAutocompleted, but not cross-validated against the field type
Setup costOne importOne config object plus as const on each array
Runtime costNoneNone - all of it is erased
Bundle costNonecreateIam retains the validator chunk (see below)

Where each error surfaces

Loading diagram...

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 / validateRole on 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 - savePolicy and saveRole write what you give them.
  • Role-graph problems via validateRoles(): duplicate IDs, dangling inherits, 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(), and env() - once a context is declared.
  • Value types compared against a field - env('hour', 'lt', 'noon') is an error when hour is number.
  • Per-resource attribute keys under .of() and grantWhen().
  • Refactors: rename a role in the config and TypeScript flags every reference.

Neither

  • Logic bugs. Nothing tells you a rule should have been deny rather than allow, or that a priority is inverted.
  • Cross-validation of a <MathMl mathml="<span class=&quot;katex&quot;><span class=&quot;katex-mathml&quot;><math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;><semantics><mrow><mi mathvariant=&quot;normal&quot;>‘</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=&quot;normal&quot;>.</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=&quot;normal&quot; lspace=&quot;0em&quot; rspace=&quot;0em&quot;>′</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=&quot;normal&quot;>‘</mi></mrow><annotation encoding=&quot;application/x-tex&quot;>-reference against the field it is compared to. The value type is a union of the field&#x27;s type and every </annotation></semantics></math></span><span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.7778em;vertical-align:-0.0833em;&quot;></span><span class=&quot;mord&quot;>‘</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2222em;&quot;></span><span class=&quot;mbin&quot;>−</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2222em;&quot;></span></span><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.9463em;vertical-align:-0.1944em;&quot;></span><span class=&quot;mord mathnormal&quot;>re</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.10764em;&quot;>f</span><span class=&quot;mord mathnormal&quot;>ere</span><span class=&quot;mord mathnormal&quot;>n</span><span class=&quot;mord mathnormal&quot;>ce</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>g</span><span class=&quot;mord mathnormal&quot;>ain</span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&quot;>tt</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.10764em;&quot;>f</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>d</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>sco</span><span class=&quot;mord mathnormal&quot;>m</span><span class=&quot;mord mathnormal&quot;>p</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot;>re</span><span class=&quot;mord mathnormal&quot;>d</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>o</span><span class=&quot;mord&quot;>.</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.13889em;&quot;>T</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>v</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>y</span><span class=&quot;mord mathnormal&quot;>p</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>ni</span><span class=&quot;mord mathnormal&quot;>o</span><span class=&quot;mord mathnormal&quot;>n</span><span class=&quot;mord mathnormal&quot;>o</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.10764em;&quot;>f</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.10764em;&quot;>f</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord&quot;><span class=&quot;mord mathnormal&quot;>d</span><span class=&quot;msupsub&quot;><span class=&quot;vlist-t&quot;><span class=&quot;vlist-r&quot;><span class=&quot;vlist&quot; style=&quot;height:0.7519em;&quot;><span style=&quot;top:-3.063em;margin-right:0.05em;&quot;><span class=&quot;pstrut&quot; style=&quot;height:2.7em;&quot;></span><span class=&quot;sizing reset-size6 size3 mtight&quot;><span class=&quot;mord mtight&quot;><span class=&quot;mord mtight&quot;>′</span></span></span></span></span></span></span></span></span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>y</span><span class=&quot;mord mathnormal&quot;>p</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>an</span><span class=&quot;mord mathnormal&quot;>d</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>v</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>ery</span><span class=&quot;mord&quot;>‘</span></span></span></span>"/>-path, so mismatched pairs compile.
  • Circular role inheritance as a failure. validateRoles() reports CIRCULAR_INHERIT as a warning and leaves valid at true.
  • 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

Loading diagram...

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

  • createIam is not free in bundle terms. The root entry point withholds the validator functions so the roughly 12 KB chunk stays opt-in, but createIam closes over validatePolicy and validateRoles to expose them as methods. A browser bundle that calls createIam carries the validator whether or not it validates anything.
  • as const is easy to forget and silent when missed. An array that widened to string[] produces a config where everything compiles - the same code you had before the migration, minus the checks. Grep for createIam( and confirm each array.
  • Typed does not mean validated. Nothing about createIam runs at runtime. Data crossing a trust boundary still needs validatePolicy().

See also