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
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
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
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
@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:
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:
@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
}
}
}// 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
// app.config.ts
import { provideAbilities } from 'ng-ability'
export const appConfig: ApplicationConfig = {
providers: [
provideAbilities(AbilityUserContext, [
ArticleAbility,
CommentAbility,
]),
],
}Lazy route with additional abilities
// 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
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:
@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:
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)
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
@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
bootstrapApplication(AppComponent, {
providers: [
provideAbilities(TenantUserContext, [
TenantIsolationAbility, // global — runs first on every check
ProjectAbility,
]),
],
})Now can(project, 'edit') will:
- Check
TenantIsolationAbility— reject ifproject.tenantIddoesn't match the user's - Only if the global check passes, check
ProjectAbilityfor the specific action
Permission-Aware Navigation
Computed approach (in code)
Build a navigation list that automatically updates when the user's permissions change:
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:
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).