Skip to content
tsbouncerpreview
reference

Model builders

defineModel, defineType, relation, permission, ttu, wildcard, and defineCondition.

The builders, in scope for every example on this page:

TypeScript
import type { SetExpression } from '@tsbouncer/tsbouncer';
import { createAuthz, defineCondition, defineModel, defineType, permission, relation, ttu, wildcard } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';

const store = memoryStore();

defineModel

TypeScript
defineModel<C extends ModelConfig>(config: C): Model

Validates immediately and throws ModelDefinitionError on: no types, a name that is not [a-z_][a-z0-9_]*, a condition registered under a different name, a relation and permission sharing a name, a cross-type reference to a relation that does not exist, or a cycle the validator can see.

TypeScript
const model = defineModel({
  types: { /* … */ },
  conditions: { /* … */ },
});

The generic parameter is load-bearing: it is what makes PermissionOf<typeof model> yield literal permission names rather than string. Do not annotate the result with a wider type.

defineType

TypeScript
defineType<const C extends TypeConfig = TypeConfig>(config?: C): C

Identity at runtime. The generic is the point — it preserves the literal config so the type-level derivations can see the relation and permission names.

TypeScript
user: defineType({}),                                  // no relations, no permissions
team: defineType({ relations: { member: relation(['user']) } }),
doc: defineType({
  relations: { owner: relation(['user']) },
  permissions: { read: permission.or('owner') },
}),

relation

TypeScript
relation(type: string | readonly string[], options?: { through?: string }): SetExpression

Declares an edge tuples may be written against.

TypeScript
const one: SetExpression = relation('user');
const several: SetExpression = relation(['user', 'team']);
const userset: SetExpression = relation('team', { through: 'member' });
void [one, several, userset];

wildcard

TypeScript
wildcard(type: string): SetExpression

Declares that a relation accepts a type:* subject. It is a write-side declaration: the relation grants nobody until a tuple exists.

ttu

TypeScript
ttu(through: string, target: string): SetExpression

“Follow through, then take target over there.” This is how a permission inherits — a userset edge names a relation, and target is usually a permission.

TypeScript
folder: defineType({
  relations: { viewer: relation('user'), parent: relation('folder') },
  permissions: { read: permission.or('viewer', ttu('parent', 'read')) },
});

permission

TypeScript
permission.or(...children): SetExpression
permission.allOf(...children): SetExpression

Child is a relation name or another SetExpression, so expressions nest.

TypeScript
permissions: {
  read: permission.or('owner', 'editor', ttu('parent', 'read')),
  write: permission.allOf('owner', 'editor'),
  admin: permission.allOf(permission.or('owner', 'admin'), 'active'),
}

SetExpression also carries fluent or, and, and except:

TypeScript
viewer: relation('user')
  .or(relation('team', { through: 'member' }))
  .or(relation('role', { through: 'holder' }))
  .or(wildcard('user'));

defineCondition

TypeScript
defineCondition(
  name: string,
  predicate: (context: ConditionContext) => boolean,
  options?: { params?: Record<string, 'string' | 'number' | 'boolean'> },
): ConditionDef

The predicate is code in the model; only the name and the bound parameters live on the tuple.

TypeScript
sameRegion: defineCondition(
  'sameRegion',
  (ctx) => {
    const { region, callerRegion } = ctx;
    if (typeof region !== 'string' || typeof callerRegion !== 'string') return false;
    return region === callerRegion;
  },
  { params: { region: 'string' as const } },
);

Next