typed vs untyped comparison
Side-by-side — what each approach catches, what each misses, and when to use which.
At a glance
| Concern | Untyped | Typed (createAccessConfig) |
|---|---|---|
Action typo ('raed' vs 'read') | Silent runtime fail | Compile error |
| Unknown resource type | Silent runtime fail | Compile error |
| Misspelled role ID | Silent runtime fail (unknown role) | Compile error |
| Wrong scope value | Silent runtime fail | Compile error |
| Wrong field path in conditions | Silent runtime fail (resolves to null) | With typed context: compile error |
| Wrong value type (e.g. string for number field) | Silent — operator returns false | With typed context: compile error |
engine.can() arg validation | None | Constrained to declared schema |
engine.permissions() batch validation | None | Constrained via access.checks() |
| Setup verbosity | Minimal | Slightly more |
| Bundle size impact | None | None (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()(withcontext) - 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
denynotallow") - 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 constis 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.