Pure ABAC guard composition library — composable, type-safe, zero dependencies
access-lib is a pure Attribute-Based Access Control (ABAC) library for TypeScript. Guards are composable boolean units. You define conditions, compose them with boolean algebra, and wrap functions or check access points.
No roles table. No action registry. No policy DSL. Just conditions and composition.
Standard Schema integration means any schema library with a conforming adapter works as the validator — Zod, Valibot, ArkType, and others.
- Guards as boolean algebra — compose with
.and(),.or(),.not()to build any access expression - Type-safe composition — Standard Schema validates context and resource before any condition runs
- Two modes — curried
Guard(pass context explicitly) or boundBoundGuard(context resolved from a provider) - Immutable — every composition method returns a new guard, the original is never mutated
- Zero runtime dependencies — Standard Schema is a type-only peer dependency; no runtime overhead
npm install @yaos-git/access-libStandard Schema must be available as a peer dependency. It is installed automatically in most setups.
npm install @standard-schema/specInstall your preferred schema library and use it with createGuard:
# Zod (example)
npm install zodimport { createGuard, AccessDeniedError } from '@yaos-git/access-lib';
import { z } from 'zod';
// 1. Define schemas
const contextSchema = z.object({
userId: z.string(),
role: z.enum(['admin', 'editor', 'viewer']),
});
const resourceSchema = z.object({
id: z.string(),
ownerId: z.string(),
status: z.enum(['draft', 'published']),
});
// 2. Create a factory
const factory = createGuard({ contextSchema, resourceSchema });
// 3. Define guards
const isAdmin = factory(({ context }) => context.role === 'admin');
const isOwner = factory(({ context, resource }) => context.userId === resource.ownerId);
const isDraft = factory(({ resource }) => resource.status === 'draft');
// 4. Compose guards
const canPublish = isOwner.and(isDraft).or(isAdmin);
// 5. Check access
const doc = { id: 'doc-1', ownerId: 'user-1', status: 'draft' as const };
const author = { userId: 'user-1', role: 'viewer' as const };
const allowed = await canPublish.can(author, doc); // true
// 6. Protect a function
async function publishDocument(doc: typeof resourceSchema._type) {
return `published ${doc.id}`;
}
const protectedPublish = canPublish.protect(publishDocument);
try {
const result = await protectedPublish(author)(doc);
} catch (err) {
if (err instanceof AccessDeniedError) {
console.error('Access denied:', err.context, err.resource);
}
}Creates a guard factory bound to a context schema and a resource schema.
function createGuard<TContext, TResource>(
config: GuardFactoryConfig<TContext, TResource>,
): GuardFactoryResult<TContext, TResource>;
function createGuard<TContext, TResource>(
config: GuardFactoryConfigWithProvider<TContext, TResource>,
): BoundGuardFactoryResult<TContext, TResource>;| Config field | Type | Required | Description |
|---|---|---|---|
contextSchema |
StandardSchemaV1<TContext> |
Yes | Schema to validate the access context |
resourceSchema |
StandardSchemaV1<TResource> |
Yes | Schema to validate the resource being accessed |
contextProvider |
() => TContext | Promise<TContext> |
No | If provided, returns BoundGuard instances |
Without contextProvider — returns a factory that produces Guard instances. Call .can(context, resource) with both arguments.
const factory = createGuard({ contextSchema, resourceSchema });
const isAdmin = factory(({ context }) => context.role === 'admin');With contextProvider — returns a factory that produces BoundGuard instances. Context is resolved automatically. Call .can(resource) with only the resource.
const factory = createGuard({
contextSchema,
resourceSchema,
contextProvider: () => getCurrentSession().user,
});
const isAdmin = factory(({ context }) => context.role === 'admin');Creates a guard from a condition function.
type Condition<TContext, TResource> = (params: {
context: TContext;
resource: TResource;
}) => boolean | Promise<boolean>;
factory(condition: Condition<TContext, TResource>): Guard<TContext, TResource>
// or BoundGuard if factory was created with contextProviderThe condition receives validated context and resource — schemas are applied before the condition runs.
Creates a new factory that prepends a base condition to every guard it produces. Conditions are evaluated in sequence with short-circuit semantics: if the base condition returns false, the guard's own condition is never evaluated.
factory.extend(baseCondition: Condition<TContext, TResource>): typeof factory// All guards from adminFactory will first require role === 'admin'
const adminFactory = factory.extend(({ context }) => context.role === 'admin');
const canDeleteOwn = adminFactory(
({ context, resource }) => context.userId === resource.ownerId,
);The original factory is not affected. factory.extend() returns a new independent factory.
Returned by createGuard factories without a contextProvider. Both context and resource must be passed explicitly.
Validates inputs and evaluates the condition.
guard.can(context: TContext, resource: TResource): Promise<boolean>Throws ValidationError if either input fails schema validation.
Wraps an async function. Returns a curried function: call with context first, then with the original function's arguments.
guard.protect(fn: (...args: TArgs) => Promise<TReturn>): (context: TContext) => (...args: TArgs) => Promise<TReturn>const protectedFn = isAdmin.protect(deleteDocument);
// Pre-bind context and reuse
const asAdmin = protectedFn(adminCtx);
await asAdmin(doc1);
await asAdmin(doc2);Throws AccessDeniedError when the guard denies access.
Returned by createGuard factories with a contextProvider. Context is resolved from the provider on every call.
Resolves context from the provider, validates both inputs, and evaluates the condition.
boundGuard.can(resource: TResource): Promise<boolean>Wraps an async function. Returns a flat function — context is resolved automatically from the provider.
boundGuard.protect(fn: (...args: TArgs) => Promise<TReturn>): (...args: TArgs) => Promise<TReturn>const protectedFn = boundIsAdmin.protect(deleteDocument);
await protectedFn(doc); // context resolved from providerAll composition methods are available on both Guard and BoundGuard. Each method accepts either a guard instance or a raw Condition function.
Returns a new guard that passes when both sides pass. Short-circuits on the first false.
guard.and(other: Condition<TContext, TResource> | Guard<TContext, TResource>): Guard<TContext, TResource>Returns a new guard that passes when either side passes. Short-circuits on the first true.
guard.or(other: Condition<TContext, TResource> | Guard<TContext, TResource>): Guard<TContext, TResource>Returns a new guard that inverts the result of the original.
guard.not(): Guard<TContext, TResource>Composition examples:
// Owner AND draft
const canEditDraft = isOwner.and(isDraft);
// (Owner AND draft) OR admin
const canPublish = isOwner.and(isDraft).or(isAdmin);
// Not published
const notPublished = isPublished.not();
// Inline condition
const canEditLong = isOwner.and(({ resource }) => resource.id.length > 0);| Method | Semantics | Short-circuit |
|---|---|---|
.and(other) |
Both must pass | Yes — stops at first false |
.or(other) |
Either must pass | Yes — stops at first true |
.not() |
Inverts the result | N/A |
Thrown by .protect() when the guard denies access. Extends Error.
class AccessDeniedError extends Error {
readonly context: unknown; // validated context value
readonly resource: unknown; // validated resource value
}try {
await protectedFn(viewer)(doc);
} catch (err) {
if (err instanceof AccessDeniedError) {
// err.context — who was denied
// err.resource — what they were denied access to
}
}Thrown by .can() and .protect() when schema validation fails. Extends Error.
class ValidationError extends Error {
readonly issues: readonly StandardSchemaV1.Issue[];
}Validation is applied to both context and resource before the condition runs. If validation fails, the condition is never evaluated.
| Script | Description |
|---|---|
npm run dev |
Run TypeScript checking and test watcher concurrently |
npm run dev:typescript |
Run TypeScript type checking in watch mode |
npm run dev:test |
Run Vitest in watch mode |
| Script | Description |
|---|---|
npm run build |
Bundle with esbuild and emit declarations |
| Script | Description |
|---|---|
npm run lint |
Run type checking, linting, formatting, and audit |
npm run lint:types |
Run TypeScript type checking only |
npm run lint:fix |
Check and fix linting issues with Biome |
npm run lint:format |
Format all files with Biome |
npm run lint:check |
Check code for linting issues with Biome |
npm run lint:audit |
Run npm audit |
| Script | Description |
|---|---|
npm test |
Run all tests (unit, types, e2e) |
npm run test:unit |
Run unit tests |
npm run test:types |
Run type-level tests |
npm run test:e2e |
Run end-to-end tests |
npm run coverage |
Run tests with coverage report |
- Standard Schema — Type-only peer dep; any conforming schema library works at runtime
- Zod — Used in dev and tests only; not a runtime dependency
- esbuild — Fast bundler
- tsgo — TypeScript type checker (native preview)
- Vitest — Unit and type-level testing framework
- Biome — Linter and formatter
access-lib/
├── src/
│ ├── core/
│ │ ├── BoundGuard/ # BoundGuard class (contextProvider mode)
│ │ │ ├── index.ts
│ │ │ └── index.test.ts
│ │ ├── Condition/ # Boolean evaluation helpers (and/or/not)
│ │ │ ├── index.ts
│ │ │ └── index.test.ts
│ │ ├── Guard/ # Guard class (explicit context mode)
│ │ │ ├── index.ts
│ │ │ └── index.test.ts
│ │ ├── GuardFactory/ # createGuard factory function
│ │ │ ├── index.ts
│ │ │ └── index.test.ts
│ │ └── Validator/ # Standard Schema validation adapter
│ │ ├── index.ts
│ │ └── index.test.ts
│ ├── types/
│ │ ├── Error/ # AccessDeniedError, ValidationError
│ │ │ └── index.ts
│ │ └── Guard/ # Condition, GuardFactoryConfig types
│ │ ├── index.ts
│ │ └── index.test-d.ts
│ └── index.ts # Public API surface
├── examples/
│ ├── basic-guards/ # createGuard and .can() walkthrough
│ ├── guard-composition/ # .and(), .or(), .not() walkthrough
│ └── protect-wrapper/ # .protect() and factory.extend() walkthrough
├── biome.json # Biome configuration
├── tsconfig.json # TypeScript base configuration
├── tsconfig.app.json # App TypeScript configuration
├── tsconfig.vitest.json # Vitest TypeScript configuration
├── vitest.config.ts # Vitest shared configuration
├── vitest.unit.config.ts # Unit test configuration
├── vitest.type.config.ts # Type test configuration
├── vitest.e2e.config.ts # E2E test configuration
├── esbuild.config.js # esbuild bundler configuration
└── package.json
This project uses a custom versioning scheme: MAJORYY.MINOR.PATCH
| Part | Description | Example |
|---|---|---|
MAJOR |
Major version number | 1 |
YY |
Year (last 2 digits) | 26 for 2026 |
MINOR |
Minor version | 0 |
PATCH |
Patch version | 0 |
Example: 126.0.0 = Major version 1, released in 2026, minor 0, patch 0
Conventions for contributing to this project. All rules are enforced by code review; Biome handles formatting and lint.
- Named exports only — no
export default. Every module usesexport function,export const,export class, orexport type. import type— always useimport typefor type-only imports..jsextensions — all relative imports use explicit.jsextensions (ESM requirement).
- PascalCase for class and type names.
- camelCase for functions, variables, and instances.
- PascalCase directories for
core/andtypes/modules.
- Tab indentation — enforced by Biome.
- Single quotes for string literals — enforced by Biome.
- Use
typefor all type definitions — neverinterface. - Shared types live in
src/types/TypeName/index.tswith a co-locatedTypeName.test-d.ts. - No duplicate type definitions — import from the canonical source.
- Every module has a co-located test file.
- Type-level tests use
index.test-d.tswithexpectTypeOf/assertType. - Unit tests use
index.test.tswith Vitest.
ISC