typed $ references
DollarPaths utility — typed $-prefixed value autocomplete for dynamic cross-field comparisons.
DollarPaths utility
When you provide a typed context, value positions in condition builders accept $-prefixed references with full autocomplete. This is powered by the DollarPaths utility type:
type DollarPaths<TContext> = `$${DotPaths<TContext>}`
// e.g. '$subject.id' | '$subject.roles' | '$subject.attributes.status' | '$resource.id' | ...type DollarPaths<TContext> = `$${DotPaths<TContext>}`
// e.g. '$subject.id' | '$subject.roles' | '$subject.attributes.status' | '$resource.id' | ...DollarPaths<TContext> maps every dot-path in your context into a $-prefixed template literal. These $-references are available on the value parameter of .check(), .eq(), .neq(), .attr(), .resourceAttr(), and .env(), anywhere you can compare against a dynamic value from the request context.
Usage
access
.policy('owner-only')
.rule('deny-non-owner', (r) =>
r
.deny()
.on('update', 'delete')
.of('post')
.when((w) =>
w
// Full autocomplete: '$subject.id', '$subject.roles', '$subject.attributes.status', ...
.resourceAttr('ownerId', 'neq', '$subject.id')
// Cross-attribute comparison
.attr('status', 'eq', '$resource.attributes.status'),
),
)
.build()access
.policy('owner-only')
.rule('deny-non-owner', (r) =>
r
.deny()
.on('update', 'delete')
.of('post')
.when((w) =>
w
// Full autocomplete: '$subject.id', '$subject.roles', '$subject.attributes.status', ...
.resourceAttr('ownerId', 'neq', '$subject.id')
// Cross-attribute comparison
.attr('status', 'eq', '$resource.attributes.status'),
),
)
.build()No as string cast is needed. The value parameter accepts both literal values (e.g. 'draft') and $-references (e.g. '$subject.id') in a single union type.
How the type narrowing works
Value parameters use ConditionValue internally plus FlexibleDollarPaths at the method
signature level. Non-string types (like number for env('hour', ...)) pass through
unchanged, so passing a string where a number is expected is a compile error. The
FlexibleDollarPaths<TContext> union is added directly to each method signature, not
nested inside computed types, so the IDE can see and suggest the $-prefixed literals.
Optional properties (e.g. yearsExperience?: number) are handled correctly by stripping
undefined before type resolution.
The & {} intersection trick is used internally so that even when the literal type is string, autocomplete still shows $-prefixed suggestions. Without this, TypeScript would resolve to plain string and lose all $-suggestions.
Where typed $-references are accepted
| Method | Right-hand value supports $? |
|---|---|
check(field, op, value) | yes |
eq(field, value) | yes |
neq(field, value) | yes |
attr(key, op, value) | yes |
resourceAttr(key, op, value) | yes |
env(key, op, value) | yes |
gt, gte, lt, lte (numbers) | yes — $path resolving to a number works |
in, nin (array values) | array literals only — $path to an array isn't auto-supported |
contains, not_contains | yes |
starts_with, ends_with, matches | yes (string-resolving paths) |
exists, not_exists | no value parameter |
Common patterns
Owner check
.when((w) => w.resourceAttr('ownerId', 'eq', '$subject.id'))
// Editor autocomplete shows: '$subject.id', '$subject.attributes.tier', '$resource.id', ....when((w) => w.resourceAttr('ownerId', 'eq', '$subject.id'))
// Editor autocomplete shows: '$subject.id', '$subject.attributes.tier', '$resource.id', ...Or use the shortcut:
.when((w) => w.isOwner())
// Generates the same condition under the hood.when((w) => w.isOwner())
// Generates the same condition under the hoodCross-field equality
.when((w) => w.attr('department', 'eq', '$resource.attributes.department'))
// User's department must match resource's department.when((w) => w.attr('department', 'eq', '$resource.attributes.department'))
// User's department must match resource's departmentSelf-action prevention
.when((w) => w.check('resource.id', 'eq', '$subject.id'))
// Used with `deny()` to prevent users from acting on their own account record.when((w) => w.check('resource.id', 'eq', '$subject.id'))
// Used with `deny()` to prevent users from acting on their own account recordScope match
.when((w) => w.check('resource.attributes.scope', 'eq', '$scope'))
// Resource's scope attribute must equal the request scope.when((w) => w.check('resource.attributes.scope', 'eq', '$scope'))
// Resource's scope attribute must equal the request scopeStrict typing
When the field side resolves to a narrow union and the value side is a $-reference, TypeScript validates that the resolved type at the $ path could be assignable to the field type:
interface AppContext extends DefaultContext {
subject: { attributes: { tier: 'free' | 'pro' } }
resource: { attributes: { tier: 'free' | 'pro' } }
}
const access = createAccessConfig({
actions: ['read'] as const,
resources: ['post'] as const,
context: {} as AppContext,
})
// OK — both sides resolve to 'free' | 'pro'
access.when().check('resource.attributes.tier', 'eq', '$subject.attributes.tier')
// In practice the type system can't always cross-validate $-paths against field types
// — it relies on the union at the method signature levelinterface AppContext extends DefaultContext {
subject: { attributes: { tier: 'free' | 'pro' } }
resource: { attributes: { tier: 'free' | 'pro' } }
}
const access = createAccessConfig({
actions: ['read'] as const,
resources: ['post'] as const,
context: {} as AppContext,
})
// OK — both sides resolve to 'free' | 'pro'
access.when().check('resource.attributes.tier', 'eq', '$subject.attributes.tier')
// In practice the type system can't always cross-validate $-paths against field types
// — it relies on the union at the method signature levelFor strong cross-path type checking, define narrow attribute types and avoid string/AttributeValue fallbacks where you care.
When to drop down to check()
The attr/resourceAttr/env shortcuts are typed against narrow attribute keys. For arbitrary field paths, use check():
.when((w) => w.check('environment.region', 'eq', '$subject.attributes.homeRegion')).when((w) => w.check('environment.region', 'eq', '$subject.attributes.homeRegion'))This compiles even when region and homeRegion aren't typed in your context — check() accepts any string path. You lose the dot-path autocomplete but gain flexibility.