## At a glance

| Concern | Untyped | Typed (`defineIam`) |
| --- | --- | --- |
| 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()` (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](/duck-iam/advanced/engine/caching).

***

## Untyped (direct imports)

```typescript
import { defineRole, IamEngine, IamMemoryAdapter, definePolicy } from '@gentleduck/iam'

const viewer = defineRole('viewer')
  .grant('raed', 'post') // typo: "raed" instead of "read" - NO error
  .build()

const restrictPolicy = definePolicy('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 IamEngine({ 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 (defineIam)

```typescript
import { defineIam } from '@gentleduck/iam'

const access = defineIam({
  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:**

```typescript
import { defineRole, definePolicy, IamEngine, IamMemoryAdapter } from '@gentleduck/iam'

const viewer = defineRole('viewer').grant('read', 'post').build()
const engine = new IamEngine({ adapter })
```

**After:**

```typescript
import { defineIam } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'

const access = defineIam({
  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 change the builder entry points.

***

## Mixing both

You can mix typed and untyped builders in one app:

```typescript
import { definePolicy } from '@gentleduck/iam'

const access = defineIam({
  actions: ['read'] as const,
  resources: ['post'] as const,
})

// Typed for app-defined policies
const myPolicy = access.definePolicy('my-app').rule(/* ... */).build()

// Untyped for dynamic policies loaded from DB
const dynamicPolicy = definePolicy('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.