field-guard
v0.4.0
Published
A lightweight, fully type-safe, field-level access control library for TypeScript
Downloads
368
Maintainers
Readme
field-guard
A lightweight, fully type-safe, field-level access control library for TypeScript.
Define who can see which fields of your data — with zero runtime dependencies.
Why field-guard?
CASL is a great general-purpose authorization library — but if all you need is field-level visibility control in a TypeScript + ORM codebase, it can feel like more than you bargained for:
- Runtime subject tagging. CASL requires
subject('Post', post)to identify types at runtime. ORM results (e.g. from Drizzle) don't carry a__typename-like field, so you end up wrapping every query result manually. - Type inference gaps. The
SubjectTypesystem can lose type information, reducing TypeScript's value. - Broad API surface. CASL covers far more than field visibility — which is powerful, but also means more concepts to learn for a narrow use case.
field-guard is purpose-built for that narrow use case:
- ORM results go in directly — no
subject()wrapping, no__typenameinjection - Missing fields cause compiler errors — the check function's target type enforces what's needed
- Minimal API —
defineGuard,combineGuards, and you're done
Features
- 🔒 Field-level access control — Grant or deny access per field, per access level
- 🏗️ Builder pattern API — Chainable
.withDerive()and.withCheck()for composable guards - 🔀 Merge strategies — Combine multiple verdicts via
unionorintersection - 🧩 Composable — Combine multiple guards into a single object with
combineGuards - 🦺 Fully type-safe — All fields, levels, and results are inferred from your definitions
Installation
npm install field-guardimport { defineGuard, combineGuards, mergeFieldVerdicts } from "field-guard";Core Concepts
| Concept | Description |
| ------------- | --------------------------------------------------------------------------- |
| Field | A string literal representing a property name (e.g. "id", "email") |
| Level | An access level label (e.g. "owner", "public", "admin") |
| Policy | A mapping from levels to field permissions (true, false, or per-field) |
| Verdict | The resolved result: a list of allowed fields with helper methods |
| Context | Arbitrary user/session data passed into guards at evaluation time |
Usage
1. Define a Guard
import { defineGuard } from "field-guard";
type Ctx = { userId: string; role: "admin" | "user" };
type User = { id: string; email: string; name: string };
const userGuard = defineGuard<Ctx>()({
fields: ["id", "email", "name"],
policy: {
owner: true, // all fields allowed
other: { id: true, name: true }, // whitelist mode — only id and name
},
});
fieldsandpolicyare both optional. You can omit either or both depending on your use case. See Flexible Guard Definitions below.
Policy Modes
true— Allow all fields for this levelfalse— Deny all fields for this level- Whitelist
{ id: true, name: true }— Only explicitly listed fields are allowed - Blacklist
{ secretField: false }— All fields allowed except those set tofalse
The mode is auto-detected: if any value is
true, it's whitelist mode; if all values arefalse, it's blacklist mode.
2. Add a Target Check
Use .withCheck<T>() to resolve the access level based on the context and a target object:
const userGuard = defineGuard<Ctx>()({
fields: ["id", "email", "name"],
policy: {
owner: true,
other: { id: true, name: true },
},
}).withCheck<User>()(({ ctx, target, verdictMap }) => {
const level = ctx.userId === target.id ? "owner" : "other";
return verdictMap[level];
});3. Evaluate the Guard
const guard = userGuard.for({ userId: "1", role: "user" });
const verdict = guard.check({ id: "1", email: "[email protected]", name: "Me" });
verdict.allowedFields; // ["id", "email", "name"]
const verdict2 = guard.check({ id: "2", email: "[email protected]", name: "Other" });
verdict2.allowedFields; // ["id", "name"]4. Use Verdict Helpers
Each FieldVerdict comes with convenience methods:
verdict.coversAll(["id", "name"]); // true — all requested fields are allowed
verdict.coversSome(["email"]); // true — at least one requested field is allowed
// Pick only allowed fields from an object
verdict.pick({ id: "1", email: "[email protected]", name: "Me" });
// => { id: "1", email: "[email protected]", name: "Me" } (owner)
// => { id: "1", name: "Me" } (other)5. Flexible Guard Definitions
fields and policy are both optional. This lets you use defineGuard purely for context-based logic (via .withDerive / .withCheck) without declaring any field-level policy.
// No arguments — derive-only guard
const roleGuard = defineGuard<Ctx>()()
.withDerive(({ ctx }) => ({
isAdmin: ctx.role === "admin",
}));
const g = roleGuard.for({ userId: "1", role: "admin" });
g.isAdmin; // true// Empty object — equivalent to no arguments
const guard = defineGuard<Ctx>()({});// fields only — no policy needed
const guard = defineGuard<Ctx>()({
fields: ["id", "email", "name"],
});
guard.fields; // ["id", "email", "name"]// policy only — fields defaults to []
const guard = defineGuard<Ctx>()({
policy: { admin: true, user: false },
});6. Derive Extra Properties
Use .withDerive() to compute additional properties from the context:
const guard = defineGuard<Ctx>()({
fields: ["id", "email"],
policy: { public: true },
}).withDerive(({ ctx }) => ({
isAdmin: ctx.role === "admin",
}));
const g = guard.for({ userId: "1", role: "admin" });
g.isAdmin; // true7. Row-Level Filtering with withDerive
withDerive can also produce row-level filter conditions (similar to RLS) from the context. This lets you co-locate both row-level and field-level access rules in a single guard definition.
Example with Drizzle ORM
import { eq } from "drizzle-orm";
import { defineGuard } from "field-guard";
type Ctx = { userId: string; role: "admin" | "user" };
type Post = { id: string; content: string; authorId: string };
const postGuard = defineGuard<Ctx>()({
fields: ["id", "content", "authorId"],
policy: {
owner: true,
other: { id: true, content: true },
},
})
.withDerive(({ ctx }) => ({
where: ctx.role === "admin"
? undefined
: eq(posts.authorId, ctx.userId),
}))
.withCheck<Post>()(({ ctx, target, verdictMap }) => {
const level = ctx.userId === target.authorId ? "owner" : "other";
return verdictMap[level];
});Usage
const g = postGuard.for({ userId: "1", role: "user" });
// Row-level: apply the derived `where` condition to your query
const rows = await db.select().from(posts).where(g.where);
// Field-level: pick only allowed fields per row
const results = rows.map((row) => g.check(row).pick(row));Row-level filtering decides which rows a user can access. Field-level filtering decides which fields are visible in each row. By combining both in a single guard, your access rules stay in one place.
8. Combine Multiple Guards
Use combineGuards to bundle guards for different resources and bind them all at once:
import { combineGuards } from "field-guard";
const guards = combineGuards<Ctx>()({
users: userGuard,
posts: postGuard,
});
const g = guards.for({ userId: "1", role: "user" });
g.users.check({ id: "1", email: "[email protected]", name: "A" });
g.posts.check({ id: "p1", content: "hello", authorId: "1" });9. Merge Verdicts
Merge multiple verdicts with union (any-of) or intersection (all-of) strategy:
import { mergeFieldVerdicts } from "field-guard";
// Union: field is allowed if ANY verdict allows it
mergeFieldVerdicts("union", [verdictA, verdictB], fields);
// Intersection: field is allowed only if ALL verdicts allow it
mergeFieldVerdicts("intersection", [verdictA, verdictB], fields);This is also available as mergeVerdicts on every guard instance:
const guard = defineGuard<Ctx>()({ /* ... */ });
const verdict = guard.mergeVerdicts("union", { owner: true, admin: false });API Reference
defineGuard<Context>()
Returns a factory function that accepts an optional { fields?, policy? } object and returns a guard chain. Both fields and policy can be omitted — the argument itself can also be omitted entirely.
Guard Chain Methods
| Method | Description |
| ------------------------- | ------------------------------------------------------------------ |
| .withDerive(fn) | Add derived properties computed from context |
| .withCheck<Target>()(fn) | Add a check(target) method that resolves a verdict per target |
| .for(ctx) | Bind context and return the resolved guard object |
Guard Base Properties
| Property | Description |
| --------------- | -------------------------------------------------------- |
| fields | The full list of field names |
| verdictMap | Pre-computed FieldVerdictMap for each level |
| mergeVerdicts | Helper to merge verdicts by level flags |
combineGuards<Context>()(guards)
Combines multiple guards into a single object with a shared .for(ctx) method.
mergeFieldVerdicts(mode, verdicts, fields)
Merges an array of FieldVerdict objects using "union" or "intersection" strategy.
FieldVerdict<F>
| Property | Type | Description |
| ---------------- | ----------------------- | ---------------------------------------- |
| allowedFields | F[] | List of allowed field names |
| coversAll(fs) | (fields: F[]) => boolean | true if all given fields are allowed |
| coversSome(fs) | (fields: F[]) => boolean | true if any given field is allowed |
| pick(obj) | (obj: T) => Partial<T> | Pick only allowed fields from an object |
License
MIT
