Skip to content

Recipes & Examples ​

Self-contained patterns for common ng-ability use cases, ordered from everyday to specialized.

RBAC (Role-Based Access Control) ​

A role hierarchy helper keeps your abilities clean and consistent.

Role hierarchy ​

typescript
const ROLE_HIERARCHY: Record<string, number> = {
  viewer: 0,
  editor: 1,
  admin:  2,
}

function hasRole(user: User | null, minimumRole: string): boolean {
  if (!user) return false
  return (ROLE_HIERARCHY[user.role] ?? -1) >= (ROLE_HIERARCHY[minimumRole] ?? Infinity)
}

Ability using the helper ​

typescript
import { AbilityFor, Ability } from 'ng-ability'

@AbilityFor(Article, 'Article')
export class ArticleAbility implements Ability<User, Article> {
  can(user: User | null, action: string, article?: Article): boolean {
    switch (action) {
      case 'view':   return true
      case 'create': return hasRole(user, 'editor')
      case 'edit':   return hasRole(user, 'editor') && user!.id === article?.authorId
      case 'delete': return hasRole(user, 'admin')
      default:       return false
    }
  }
}

Type-safe action declarations ​

typescript
declare module 'ng-ability' {
  interface AbilityActions {
    Article: 'view' | 'create' | 'edit' | 'delete'
  }
}

With this declaration, can('Article', 'editt') will show a TypeScript warning in your IDE. See Type Safety for full details.

GraphQL Integration ​

GraphQL responses are plain objects without class constructors, so instanceof matchers won't work. Use function matchers with __typename instead.

Basic __typename matcher ​

typescript
@AbilityFor((obj: any) => obj.__typename === 'Article')
export class ArticleAbility implements Ability<User, Article> {
  can(user: User | null, action: string, article?: Article): boolean {
    // ...
  }
}

Reusable matcher factory ​

If you have many GraphQL types, create a factory to avoid repetition:

typescript
function gqlMatcher<T extends { __typename: string }>(
  typename: T['__typename'],
): (obj: any) => obj is T {
  return (obj: any): obj is T => obj?.__typename === typename
}

@AbilityFor(gqlMatcher<GqlArticle>('Article'))
export class ArticleAbility implements Ability<User, GqlArticle> {
  can(user: User | null, action: string, article?: GqlArticle): boolean {
    // ...
  }
}

Combining string and function matchers ​

You can register both a string key (for can('Article', 'create') checks without an instance) and a function matcher (for GraphQL objects) on the same ability:

typescript
@AbilityFor('Article', gqlMatcher<GqlArticle>('Article'))
export class ArticleAbility implements Ability<User, GqlArticle> {
  can(user: User | null, action: string, article?: GqlArticle): boolean {
    if (user?.role === 'admin') return true

    switch (action) {
      case 'view':   return true
      case 'create': return user != null
      case 'edit':   return user?.id === article?.authorId
      default:       return false
    }
  }
}
typescript
// Works with string matcher (no instance)
ability.can('Article', 'create')

// Works with GraphQL response object
const result = await graphql.query(...)
ability.can(result.article, 'edit')

Feature Modules ​

Use provideAbilities() at the root level for your main abilities, and in lazy-loaded routes for feature-specific abilities.

Root registration ​

typescript
// app.config.ts
import { provideAbilities } from 'ng-ability'

export const appConfig: ApplicationConfig = {
  providers: [
    provideAbilities(AbilityUserContext, [
      ArticleAbility,
      CommentAbility,
    ]),
  ],
}

Lazy route with additional abilities ​

typescript
// admin.routes.ts
import { provideAbilities } from 'ng-ability'

export const adminRoutes: Routes = [
  {
    path: '',
    component: AdminLayoutComponent,
    providers: [
      provideAbilities([AdminDashboardAbility, AuditLogAbility]),
    ],
    children: [
      { path: 'dashboard', component: DashboardComponent },
      { path: 'audit',     component: AuditComponent },
    ],
  },
]

TIP

When calling provideAbilities() without a context class, the context registered at the root level is reused automatically. You only need to pass the context class once.

Reactive Filtered Lists ​

Use computed() with NgAbilityService.can() to filter entities based on the current user's permissions. The result updates automatically when the user changes.

Filter entities ​

typescript
import { Component, computed, inject, input } from '@angular/core'
import { NgAbilityService } from 'ng-ability'

@Component({
  selector: 'app-article-list',
  template: `
    @for (article of editableArticles(); track article.id) {
      <app-article-card [article]="article" />
    }
  `,
})
export class ArticleListComponent {
  readonly #ability = inject(NgAbilityService)

  articles = input<Article[]>([])

  editableArticles = computed(() =>
    this.articles().filter(a => this.#ability.can(a, 'edit')),
  )
}

Permission map for bulk UI ​

When you need to show different UI per item (edit button, delete button, etc.), compute a permission map once rather than calling can() repeatedly in the template:

typescript
@Component({
  selector: 'app-article-list',
  template: `
    @for (entry of articlesWithPerms(); track entry.article.id) {
      <div>
        <span>{{ entry.article.title }}</span>
        @if (entry.canEdit) {
          <button>Edit</button>
        }
        @if (entry.canDelete) {
          <button>Delete</button>
        }
      </div>
    }
  `,
})
export class ArticleListComponent {
  readonly #ability = inject(NgAbilityService)

  articles = input<Article[]>([])

  articlesWithPerms = computed(() =>
    this.articles().map(article => ({
      article,
      canEdit:   this.#ability.can(article, 'edit'),
      canDelete: this.#ability.can(article, 'delete'),
    })),
  )
}

Form Integration ​

Use an effect() to reactively enable or disable form controls when permissions change:

typescript
import { Component, effect, inject } from '@angular/core'
import { FormBuilder, ReactiveFormsModule } from '@angular/forms'
import { NgAbilityService } from 'ng-ability'

@Component({
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <input formControlName="title" />
      <textarea formControlName="body"></textarea>
      <button type="submit" [disabled]="!form.enabled">Save</button>
    </form>
  `,
})
export class ArticleFormComponent {
  readonly #ability = inject(NgAbilityService)
  readonly #fb = inject(FormBuilder)

  article = input.required<Article>()

  form = this.#fb.group({
    title: [''],
    body: [''],
  })

  constructor() {
    effect(() => {
      const canEdit = this.#ability.can(this.article(), 'edit')

      if (canEdit) {
        this.form.enable()
      } else {
        this.form.disable()
      }
    })
  }
}

Multi-Tenant Isolation ​

Combine a global ability for tenant-level gatekeeper logic with specific abilities for resource-level checks.

Tenant gatekeeper (global ability) ​

typescript
import { AbilityFor, Ability, GlobalAbility } from 'ng-ability'

interface TenantUser {
  id: string
  tenantId: string
  role: string
}

@AbilityFor(GlobalAbility)
export class TenantIsolationAbility implements Ability<TenantUser> {
  can(user: TenantUser | null, action: string, thing?: any): boolean {
    if (!user) return false

    // If the resource belongs to a tenant, ensure it matches the user's tenant
    if (thing?.tenantId && thing.tenantId !== user.tenantId) {
      return false
    }

    return true
  }
}

Resource-specific ability ​

typescript
@AbilityFor(Project, 'Project')
export class ProjectAbility implements Ability<TenantUser, Project> {
  can(user: TenantUser | null, action: string, project?: Project): boolean {
    switch (action) {
      case 'view':   return true
      case 'edit':   return user?.role === 'admin' || user?.role === 'editor'
      case 'delete': return user?.role === 'admin'
      default:       return false
    }
  }
}

Registration ​

typescript
bootstrapApplication(AppComponent, {
  providers: [
    provideAbilities(TenantUserContext, [
      TenantIsolationAbility,  // global — runs first on every check
      ProjectAbility,
    ]),
  ],
})

Now can(project, 'edit') will:

  1. Check TenantIsolationAbility — reject if project.tenantId doesn't match the user's
  2. Only if the global check passes, check ProjectAbility for the specific action

Permission-Aware Navigation ​

Computed approach (in code) ​

Build a navigation list that automatically updates when the user's permissions change:

typescript
import { Component, computed, inject } from '@angular/core'
import { NgAbilityService } from 'ng-ability'

interface NavItem {
  label: string
  route: string
}

@Component({
  selector: 'app-sidebar',
  template: `
    <nav>
      @for (item of visibleNav(); track item.route) {
        <a [routerLink]="item.route">{{ item.label }}</a>
      }
    </nav>
  `,
})
export class SidebarComponent {
  readonly #ability = inject(NgAbilityService)

  visibleNav = computed<NavItem[]>(() => {
    const items: NavItem[] = [
      { label: 'Articles', route: '/articles' },
    ]

    if (this.#ability.can('Article', 'create')) {
      items.push({ label: 'New Article', route: '/articles/new' })
    }

    if (this.#ability.can('AdminArea', 'view')) {
      items.push({ label: 'Admin', route: '/admin' })
    }

    return items
  })
}

Template-only approach (with can pipe) ​

For simpler cases, use the pipe directly in the template without building a navigation model:

typescript
import { CanPipe } from 'ng-ability'

@Component({
  imports: [CanPipe],
  template: `
    <nav>
      <a routerLink="/articles">Articles</a>

      @if ('Article' | can: 'create') {
        <a routerLink="/articles/new">New Article</a>
      }

      @if ('AdminArea' | can: 'view') {
        <a routerLink="/admin">Admin</a>
      }
    </nav>
  `,
})
export class SidebarComponent {}

Which approach to choose?

Use the template-only approach when you just need to show/hide links. Use the computed approach when navigation items come from a dynamic data source, or when you need the filtered list in code (e.g., for keyboard navigation or testing).

Released under the MIT License.