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,
})
context is a phantom field. createIam never reads it - it exists only so TypeScript can infer TContext from an argument position. {} as unknown as AppContext is the idiom the package's own JSDoc uses. If your codebase bans assertions, annotate the input object instead and pass it in:
import { createIam, type IamConfig } from '@gentleduck/iam'
const input: IamConfig.IAccessConfigInput<
readonly ['create', 'read', 'update', 'delete'],
readonly ['post', 'comment', 'user'],
readonly ['viewer', 'editor', 'admin'],
readonly string[],
AppContext
> = {
actions: ['create', 'read', 'update', 'delete'],
resources: ['post', 'comment', 'user'],
roles: ['viewer', 'editor', 'admin'],
}
const access = createIam(input)
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.
The compile-time assertions in src/core/types/__tests__/types.test.ts pin every branch:
| Input | DotPaths result | Rule |
|---|---|---|
{ 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> | never | An 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:
AttrPathswidens tostringfor an open bag instead of collapsing tonever.DotPath.EnvAttrs<{ environment: DotPath.IAnyAttributes }>isstring.AttrPathsrecurses only into plain objects. Arrays, functions,Date,Map, andSetare 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:
| Limit | Value | Where |
|---|---|---|
MAX_CONDITION_DEPTH | 10 | Condition-group nesting; a deeper tree fails closed and evaluates to false |
MAX_INHERITANCE_DEPTH | 32 | Role inherits expansion |
PATH_CACHE_MAX | 10_000 | Entries 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
.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
| Type | Purpose |
|---|---|
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.IAnyAttributes | The open attribute bag marker |
DotPath.IDefaultContext | The default context, open bags and all |
Gotchas
- Value autocomplete is only as narrow as your types. A field typed
stringcan 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.
HasOpenIndexrecurses through the whole context; oneRecord<string, unknown>deep inside re-enables arbitrary strings forcheck()across the board. - Extending
IDefaultContextinherits its open bags for anything you do not override. Overridesubject,resource, andenvironmentwholesale rather than partially if you want a fully closed context. resourceAttr()onaccess.when()sees the merged shape. A key that exists on only one resource still compiles there; the narrowing only happens under.of()orgrantWhen.
See also
- Typed $-references - the value side of the same machinery.
- Conditions - every operator and its edge semantics.
- createIam() - where
contextis declared. - Types and namespaces - the full
DotPathmember list.