Skip to main content

Typed context

How the context option turns your application types into dot-path autocomplete, per-resource attribute keys, and narrowed condition values

Without a context, the condition builders accept any string as a field path and IamPrimitives.AttributeValue as a value. Declaring one flips both sides to your application's real shapes: attr(), resourceAttr(), env(), check(), eq(), neq(), and in() all autocomplete their keys and narrow their values. The runtime is unchanged - this is entirely a compile-time gain.

Declaring a context

import { createIam, type DotPath } from '@gentleduck/iam'

interface AppContext extends DotPath.IDefaultContext {
  subject: {
    id: string
    roles: string[]
    attributes: {
      status: 'active' | 'banned' | 'suspended'
      department: string
    }
  }
  resource: {
    type: 'post' | 'comment' | 'user'
    id?: string
    attributes: {
      ownerId: string
      status: 'draft' | 'published' | 'archived'
    }
  }
  environment: {
    hour: number
    dayOfWeek: number
    maintenanceMode: boolean
  }
  scope: string
}

const access = createIam({
  actions: ['create', 'read', 'update', 'delete'] as const,
  resources: ['post', 'comment', 'user'] as const,
  roles: ['viewer', 'editor', 'admin'] as const,
  context: {} as unknown as AppContext,
})

Now every builder reached through access is typed:

access
  .definePolicy('banned-users')
  .rule('block-banned', (r) =>
    // 'status' autocompletes from subject.attributes;
    // the value is constrained to 'active' | 'banned' | 'suspended'
    r.deny().on('*').of('*').when((w) => w.attr('status', 'eq', 'banned')),
  )
  .build()

access
  .definePolicy('maintenance')
  .rule('deny-writes', (r) =>
    // 'maintenanceMode' autocompletes from environment; the value must be boolean
    r.deny().on('create', 'update', 'delete').of('*').when((w) => w.env('maintenanceMode', 'eq', true)),
  )
  .build()

How dot-path.ts derives the paths

DotPath.DotPaths<T> walks T once and produces a string-literal union. Each property takes exactly one branch.

Loading diagram...

The compile-time assertions in src/core/types/__tests__/types.test.ts pin every branch:

InputDotPaths resultRule
{ a: { b: string }; c: number }'a' | 'a.b' | 'c'Objects emit themselves and recurse
{ roles: string[] }'roles'Arrays are leaves, never indexed
{ fn: () => void; a: string }'a'Functions are skipped entirely
Record<string, string>neverAn index signature yields no literal paths

DotPath.PathValue<T, P> is the inverse: it splits P on the first dot and walks down, returning never when a segment is not a key of the current level. PathValue<{ a: { b: string } }, 'a.nope'> is never.

Closed contexts versus open bags

DotPath.FlexibleDotPaths<T> is what the builders actually use for check, eq, neq, in, contains, exists, gt, gte, lt, lte, and matches. It checks HasOpenIndex<T>, which recurses through every branch of T looking for a string index signature:

  • No open bag anywhere - the type is DotPaths<T> alone. A path that is not in your context is a compile error.
  • Any open bag - the type is DotPaths<T> | (string & {}). Known paths still autocomplete, but arbitrary strings are accepted.

DotPath.IDefaultContext uses IAnyAttributes for subject.attributes, resource.attributes, and environment, so it has open bags by construction. That is why the default, context-free config accepts any field path: not because checking is switched off, but because the default context is open.

Attribute-bag paths are derived differently

attr(), resourceAttr(), and env() take a key inside a bag, not a full path, so they use the internal AttrPaths helper rather than DotPaths. Two differences matter:

  • AttrPaths widens to string for an open bag instead of collapsing to never. DotPath.EnvAttrs<{ environment: DotPath.IAnyAttributes }> is string.
  • AttrPaths recurses only into plain objects. Arrays, functions, Date, Map, and Set are leaves. DotPath.SubjectAttrs<{ subject: { attributes: { profile: { tier: string } } } }> is 'profile' | 'profile.tier'.

The value side uses DotPath.AttrValue, which strips undefined before resolving, so an optional yearsExperience?: number narrows to number rather than falling back to AttributeValue.

Depth limits

There is no numeric depth cap in dot-path.ts. DotPaths, PathValue, AttrPaths, and HasOpenIndex are all unbounded recursive conditional types; the ceiling is TypeScript's own instantiation budget, and a context nested deeply enough surfaces as Type instantiation is excessively deep and possibly infinite rather than a duck-iam error. In practice contexts are three or four levels deep and never approach it.

The depth limits that do exist are at runtime and belong to other parts of the engine:

LimitValueWhere
MAX_CONDITION_DEPTH10Condition-group nesting; a deeper tree fails closed and evaluates to false
MAX_INHERITANCE_DEPTH32Role inherits expansion
PATH_CACHE_MAX10_000Entries in the resolved path-segment cache

Per-resource attribute narrowing

Add a resourceAttributes map to your context and resourceAttr() narrows its keys to whichever resource the enclosing rule selected with .of().

interface AppContext extends DotPath.IDefaultContext {
  resourceAttributes: {
    post: { ownerId: string; status: 'draft' | 'published' | 'archived'; title: string }
    comment: { ownerId: string; body: string }
    user: { email: string; status: 'active' | 'banned' }
    dashboard: { name: string }
  }
}
// .of('post') - resourceAttr accepts 'ownerId' | 'status' | 'title'
access
  .definePolicy('post-title')
  .rule('require-title', (r) =>
    r.deny().on('create', 'update').of('post').when((w) => w.not((n) => n.resourceAttr('title', 'exists'))),
  )
  .build()

// .of('comment') - resourceAttr accepts 'ownerId' | 'body'
access
  .definePolicy('comment-body')
  .rule('require-body', (r) =>
    r.deny().on('create').of('comment').when((w) => w.not((n) => n.resourceAttr('body', 'exists'))),
  )
  .build()

// .of('*') - resourceAttr accepts the merged union of every resource's keys
access
  .definePolicy('global-owner')
  .rule('deny-non-owner', (r) =>
    r.deny().on('delete').of('*').when((w) => w.resourceAttr('ownerId', 'neq', '$subject.id')),
  )
  .build()

The same narrowing applies inside grantWhen on a role, which takes the resource as its second argument:

access
  .defineRole('member')
  .grantWhen('update', 'post', (w) => w.isOwner().resourceAttr('status', 'eq', 'draft'))
  .build()

Without a resourceAttributes map, resourceAttr() falls back to DotPath.ResourceAttrShape - the plain resource.attributes type from your context - for every resource.

How TContext reaches the condition builder

Loading diagram...

.of() is the pivot. Its signature is of<R extends TResource | '*'>(...resources: R[]): RuleBuilder<TAction, TResource, TScope, TRole, TContext, R> - it returns a new builder type whose sixth parameter is the resource you named, and the when callback hands you a When carrying that same parameter. DotPath.ResolvedResourceAttrs then picks the matching entry out of resourceAttributes; for '*' (and for any resource not in the map) it falls back to MergedResourceAttrs, which collects every key declared on any resource and unions each key's value types.

A When created directly by access.when() has no active resource, so it sees the merged shape. That is the trade-off for reusable condition groups.

The type helpers, one line each

TypePurpose
DotPath.DotPaths<T>Every literal path through T; arrays are leaves, functions skipped, index signatures give never
DotPath.FlexibleDotPaths<T>DotPaths<T> for closed contexts; adds (string & {}) when any branch has an open bag
DotPath.PathValue<T, P>The value type at path P, or never
DotPath.FieldValue<T, P>PathValue wrapped in ConditionValue, falling back to AttributeValue on a miss
DotPath.ConditionValue<T, V>Passes non-string values through unchanged; adds $-paths to the string-capable half
DotPath.FlexibleDollarPaths<T>DollarPaths<T> | (string & {}), spliced into each method signature so the IDE lists the literals
DotPath.SubjectAttrShape<T>T['subject']['attributes']
DotPath.ResourceAttrShape<T>T['resource']['attributes']
DotPath.EnvAttrShape<T>T['environment']
DotPath.SubjectAttrs<T>Keys for attr()
DotPath.ResourceAttrs<T>Keys for resourceAttr() when no per-resource map exists
DotPath.EnvAttrs<T>Keys for env()
DotPath.ResourceAttrMap<T>T['resourceAttributes'], or never
DotPath.ResolvedResourceAttrs<T, R>The attribute shape for resource R; merged union for '*'
DotPath.ResolvedResourceAttrPaths<T, R>Keys for resourceAttr() under an active resource
DotPath.AttrValueAt<T, P>Raw value at P inside a bag; never on a miss
DotPath.AttrValue<T, P>AttrValueAt with undefined stripped
DotPath.IAnyAttributesThe open attribute bag marker
DotPath.IDefaultContextThe default context, open bags and all

Gotchas

  • Value autocomplete is only as narrow as your types. A field typed string can only offer broad string input plus $-references. Narrow the attributes you care about to literal unions and the value side narrows with them.
  • An open bag anywhere loosens every path. HasOpenIndex recurses through the whole context; one Record<string, unknown> deep inside re-enables arbitrary strings for check() across the board.
  • Extending IDefaultContext inherits its open bags for anything you do not override. Override subject, resource, and environment wholesale rather than partially if you want a fully closed context.
  • resourceAttr() on access.when() sees the merged shape. A key that exists on only one resource still compiles there; the narrowing only happens under .of() or grantWhen.

See also