Skip to main content

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

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

MethodRight-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_containsyes
starts_with, ends_with, matchesyes (string-resolving paths)
exists, not_existsno 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 hood

Cross-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 department

Self-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 record

Scope 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 scope

Strict 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 level
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 level

For 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.