Skip to main content

typed vs untyped comparison

Side-by-side — what each approach catches, what each misses, and when to use which.

At a glance

ConcernUntypedTyped (createAccessConfig)
Action typo ('raed' vs 'read')Silent runtime failCompile error
Unknown resource typeSilent runtime failCompile error
Misspelled role IDSilent runtime fail (unknown role)Compile error
Wrong scope valueSilent runtime failCompile error
Wrong field path in conditionsSilent runtime fail (resolves to null)With typed context: compile error
Wrong value type (e.g. string for number field)Silent — operator returns falseWith typed context: compile error
engine.can() arg validationNoneConstrained to declared schema
engine.permissions() batch validationNoneConstrained via access.checks()
Setup verbosityMinimalSlightly more
Bundle size impactNoneNone (types are erased)

The typed version catches mistakes during the edit/build cycle. The untyped version is fine for one-offs, scripts, or quick experiments.


What each catches

Both approaches catch

  • Adapter contract violations (wrong shape passed to saveRole, savePolicy)
  • Runtime errors from broken adapters or hooks
  • Cycle detection in role inheritance (validateRoles())
  • Schema drift in policies loaded from external sources (validatePolicy())

Only typed catches

  • Action / resource / role / scope typos at the call site
  • Wrong attribute keys in attr(), resourceAttr(), env() (with context)
  • Wrong value types compared to field types
  • Wrong inheritance references (inherits('typo-role-id'))
  • Wrong $-reference paths

Neither catches

  • Logic bugs ("this rule should be deny not allow")
  • Stale cached decisions
  • Concurrent attribute write races
  • Permissions modeled at the wrong granularity

For logic bugs, write tests. For caching, see engine caching.


Untyped (direct imports)

import { defineRole, Engine, MemoryAdapter, policy } from '@gentleduck/iam'
 
const viewer = defineRole('viewer')
  .grant('raed', 'post') // typo: "raed" instead of "read" — NO error
  .build()
 
const restrictPolicy = policy('restrict')
  .rule('block', (r) =>
    r
      .deny()
      .on('approval') // typo? Or a real custom action? Untyped can't tell
      .of('budget')
      .when((w) => w.attr('departmnt', 'eq', 'eng')), // typo in 'department' — NO error
  )
  .build()
 
const engine = new Engine({ adapter })
await engine.can('user-1', 'raed', { type: 'post', attributes: {} })
// Silently returns false because no role grants "raed"
import { defineRole, Engine, MemoryAdapter, policy } from '@gentleduck/iam'
 
const viewer = defineRole('viewer')
  .grant('raed', 'post') // typo: "raed" instead of "read" — NO error
  .build()
 
const restrictPolicy = policy('restrict')
  .rule('block', (r) =>
    r
      .deny()
      .on('approval') // typo? Or a real custom action? Untyped can't tell
      .of('budget')
      .when((w) => w.attr('departmnt', 'eq', 'eng')), // typo in 'department' — NO error
  )
  .build()
 
const engine = new Engine({ adapter })
await engine.can('user-1', 'raed', { type: 'post', attributes: {} })
// Silently returns false because no role grants "raed"

Pros:

  • Less ceremony
  • Works without extra imports
  • Easy to copy/paste from examples

Cons:

  • Typos compile and pass tests, fail in production
  • No autocomplete for actions / resources / roles / fields
  • Code reviews carry the burden of catching string mismatches

Typed (createAccessConfig)

import { createAccessConfig } from '@gentleduck/iam'
 
const access = createAccessConfig({
  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" | ...'
  .build()
import { createAccessConfig } from '@gentleduck/iam'
 
const access = createAccessConfig({
  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" | ...'
  .build()

Pros:

  • Typos are immediate compile errors
  • Autocomplete shows valid options at every call site
  • Refactoring is safe — rename a role and TypeScript flags every reference
  • Single source of truth: the config defines the schema once

Cons:

  • More ceremony at setup
  • as const is required (and easy to forget)
  • TypeScript errors can be cryptic when types nest deeply

For most production apps, the trade-off is clearly worth it. For weekend hacks, untyped is faster.


Migrating from untyped to typed

Drop-in: replace your imports with a single config and re-derive the types.

Before:

import { defineRole, policy, Engine, MemoryAdapter } from '@gentleduck/iam'
 
const viewer = defineRole('viewer').grant('read', 'post').build()
const engine = new Engine({ adapter })
import { defineRole, policy, Engine, MemoryAdapter } from '@gentleduck/iam'
 
const viewer = defineRole('viewer').grant('read', 'post').build()
const engine = new Engine({ adapter })

After:

import { createAccessConfig } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
const access = createAccessConfig({
  actions: ['read'] as const,
  resources: ['post'] as const,
  roles: ['viewer'] as const,
})
 
const viewer = access.defineRole('viewer').grant('read', 'post').build()
const engine = access.createEngine({ adapter })
import { createAccessConfig } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
const access = createAccessConfig({
  actions: ['read'] as const,
  resources: ['post'] as const,
  roles: ['viewer'] as const,
})
 
const viewer = access.defineRole('viewer').grant('read', 'post').build()
const engine = access.createEngine({ adapter })

The role/policy data shape is unchanged — adapters, evaluation, and serialization all work the same. You just change the builder entry points.


Mixing both

You can mix typed and untyped builders in one app:

import { policy } from '@gentleduck/iam'
 
const access = createAccessConfig({
  actions: ['read'] as const,
  resources: ['post'] as const,
})
 
// Typed for app-defined policies
const myPolicy = access.policy('my-app').rule(/* ... */).build()
 
// Untyped for dynamic policies loaded from DB
const dynamicPolicy = policy('dynamic').rule(/* ... */).build()
 
await access.validatePolicy(dynamicPolicy) // runtime check
 
// Both can be passed to the same engine
await engine.admin.savePolicy(myPolicy)
await engine.admin.savePolicy(dynamicPolicy)
import { policy } from '@gentleduck/iam'
 
const access = createAccessConfig({
  actions: ['read'] as const,
  resources: ['post'] as const,
})
 
// Typed for app-defined policies
const myPolicy = access.policy('my-app').rule(/* ... */).build()
 
// Untyped for dynamic policies loaded from DB
const dynamicPolicy = policy('dynamic').rule(/* ... */).build()
 
await access.validatePolicy(dynamicPolicy) // runtime check
 
// Both can be passed to the same engine
await engine.admin.savePolicy(myPolicy)
await engine.admin.savePolicy(dynamicPolicy)

Use untyped for the dynamic parts (admin UI input, JSON files), typed for the static parts. Validate with validatePolicy() at the runtime boundary.