Schema modeling

This guide collects practical advice for designing TypeDB schemas:

  1. Choosing between entities, relations, and attributes.

  2. Naming types and functions so that queries read naturally.

  3. Composing types out of capabilities instead of deep hierarchies.

  4. Choosing the level of type safety that fits your application.

  5. Picking the most specific cardinality for owns, relates, and plays.

  6. Placing value restrictions at the right level, and specializing inherited constraints.

  7. Constraining sub-relation participants by specializing roles.

  8. Using functions for modularity, derived facts, aggregation, and recursion.

The examples build up a single schema from top to bottom: types defined in earlier sections are reused and extended in later ones.

1. Entities, relations, and attributes

TypeDB treats relations and attributes as first-class, alongside entities (see Entities, Relations, Attributes for the underlying data model). Choosing between the three is the first modeling decision for every piece of data:

  • Use an entity when the data has an independent existence in the real world (a person, a project, a movie), one that does not depend on any relationship or specific attribute, and is not trivially representable by a single value. Entities are the basic building blocks of any TypeDB schema.

  • Use a relation when the data only exists because of a connection between other things (a product order, a group membership, an access grant), so its existence depends on the linked role players.

  • Use an attribute when the data is just a value (a size, a date, a name), entirely defined by its type and its value.

In this example, we apply these principles to build a small employment schema:

#!test[schema, commit]
define

attribute name, value string;
attribute salary, value double;

entity person,
    owns name,
    plays employment:employee;

entity company,
    owns name,
    plays employment:employer;

relation employment,
    relates employer,
    relates employee,
    owns salary;
#!test[write, commit, count = 1]
insert
$person isa person, has name "Alice";
$company isa company, has name "TypeDB";
$employment isa employment,
    links (employer: $company, employee: $person),
    has salary 100000.0;

The remainder of this guide uses the above schema as a baseline.

1.1. Entities exist independently

Entities are the standalone building blocks of a schema. An entity exists independently of every other concept. A person remains the same person whether or not they have a name, an employer, or any other data attached. Unless the schema constrains otherwise, an entity with no attributes and no relations at all is perfectly valid:

#!test[write, rollback, count = 1]
insert
$person isa person;  # a valid person, with no attributes or relations

An entity’s identity is also not a value: unlike attributes, there is no direct way to reference an entity, so queries find entities through the attributes they own and the roles they play.

1.2. Relations depend on their players

TypeDB auto-deletes any relation left with zero role players, even if it still has attributes. Here an employment is created with only an employer (each relates role defaults to @card(0..1), making the employee role optional; see the Cardinality section below), and disappears when that employer is deleted:

#!test[write, commit, count = 1]
insert
$acme isa company, has name "Acme";
$employment isa employment,
    links (employer: $acme),
    has salary 1.0;
#!test[write, commit]
match
$company isa company, has name "Acme";
delete
$company;
#!test[read, count = 0]
match
$employment isa employment, has salary 1.0;  # auto-deleted with its last player

Further consequences of relations being first-class:

  • Relations are n-ary, meaning you never need to reify relationships: extend a relation with another role instead of inventing a linking entity.

  • Relations can be nested: a relation can itself be a role player in another relation.

  • Roles are types too: relation employment, relates employee creates a role type employment:employee, scoped to its relation but otherwise a full-fledged type that can be queried like any other.

1.3. Attributes are just values

An attribute instance is entirely defined by its type and value, and it is immutable, global, and shared: owners do not contain attributes, they link to them. A single name "Alice" exists in the entire database; unrelated owners all link to the same instance:

#!test[write, commit, count = 1]
insert
$company isa company, has name "Alice";  # a company also named Alice
#!test[read, count = 1]
match
$name isa name;
$name == "Alice";  # one shared attribute instance, despite two owners

Sharing an attribute type across owners is what enables polymorphic querying over ownerships:

#!test[read, count = 2]
match
$x has name "Alice";  # the person and the company

If you want to polymorphically query ownerships like this, a shared 'global' attribute is a good model. Otherwise, use a specific attribute with a single owner; it documents the narrower intent in the schema.

Attributes default to dependent: an instance is automatically deleted when its last owner is deleted. Marking the attribute type @independent allows instances to exist freely, which is useful, for example, when pre-loading a dictionary of words or numbers.

Finally, an attribute carries a value of the value type assigned in the schema, but an @abstract attribute may leave the value type unassigned. A classic example is an abstract id, subtyped into concrete identifiers with different value types:

#!test[schema, commit]
define

attribute id @abstract;
attribute email_id sub id, value string;
attribute employee_id sub id, value integer;

2. Naming types and functions

TypeQL reads most elegantly when every type (entity, relation, role, and attribute) is named with a noun. The main TypeQL keywords (isa, has, links, relates, owns, plays) are verbs, so the types slot in as nouns around them, and queries read like English sentences.

All types: querying with match …​ isa …​ reads as "X is a Y", and the type’s label fills the Y slot: $person isa person works as a sentence because the label is a noun.

Attributes: querying with match …​ has …​, the object of the sentence comes after the has, so it reads best as a noun. Read aloud, $person has name "Alice" is a well-formed English sentence; a verb name like named would produce the awkward $person has named "Alice".

#!test[read, count = 1]
match
$person isa person, has name "Alice";

Relations: querying with match …​ links …​, the object of the sentence again comes after the verb, so a relation also reads best as a noun.

#!test[read, count = 1]
match
$employment isa employment,
    links (employer: $company, employee: $person);

Relations read best as active or verbal nouns: the noun form of the action the relation captures. For example, employment is the noun form of to employ, marriage of to marry, and assignment of to assign. Sometimes the two coincide: grant is the noun form of to grant, and review of to review. Compare the verb-named alternative: employs links (…​) reads as two verbs in a row, while employment links …​ reads naturally.

Functions should be named by what they return and the constraints they contain, e.g. persons_by_name, or principals_with_access. The call site then reads as a description of the result:

#!test[read, count = 1]
with fun persons_by_name($name: string) -> { person }:
    match
        $person isa person, has name == $name;
    return { $person };
match
let $person in persons_by_name("Alice");

Here with declares a query-level function, valid for that query only; the functions section covers persisting functions in the schema with define.

3. Subtyping vs composition

TypeDB supports only single inheritance. Treat sub as a true, independent "is-a" axis, and compose everything else. When a supertype exists only to be specialized, mark it @abstract so that only its subtypes can have instances, as with the abstract id attribute above.

To compose, use relations and roles: a role that a type plays acts as a trait or interface, much like a component architecture in OOP. We might have a plain entity person, but a person can behave as an employee: entity person, plays employment:employee. Attribute ownerships are interfaces the same way: entity person, owns name indicates a person can act as a "name-owner".

Both axes are resolved polymorphically at read time:

  • $x isa person; resolves subtype polymorphism: it matches person and everything below it.

  • $x has name $n; or $r links (employee: $x); resolve interface polymorphism: they match every type implementing the capability, as the has name "Alice" query above matched both a person and a company.

Since a type cannot sub two parents, a nice way to give one type several independent capabilities is to create unary relations (relations with a single role) that act as components for the type. Here, a strongly typed HR subsystem can be built against a person’s hr_component, of which at most one can exist:

#!test[schema, commit]
define

relation hr_component,
    relates subject;

person plays hr_component:subject @card(0..1);
#!test[write, commit, count = 1]
match
$person isa person, has name "Alice";
insert
$hr isa hr_component, links (subject: $person);

The HR subsystem can then look up a person’s component and work against it:

#!test[read, count = 1]
match
$person isa person, has name "Alice";
$as_hr isa hr_component, links (subject: $person);

4. Type safety strictness

TypeDB gives you the power to choose the level of type safety you want. The same domain can be modeled anywhere on a spectrum, from a single generic type with string labels to a fully enumerated schema where even enum values are types.

At the loose end, you can choose to avoid defining specialized types altogether, and create an entity type thing with an attribute label:

#!test[schema, commit]
define

attribute label, value string;
entity thing, owns label @card(0..);

Any kind of data fits without schema changes, but you trade off type errors for silent misses and data bugs. A mistyped label is not an error; the query silently returns no results instead of failing:

#!test[write, commit, count = 1]
insert
$t isa thing, has label "person", has label "alice";
#!test[read, count = 0]
match
$t isa thing, has label "perzon";  # typo: no error, just no results

Alternatively, you can choose to enumerate your types into the schema (like the person and company types above), and the database will type check those for you. The same typo is then an error, caught as soon as the query runs:

#!test[read, fail_at = runtime]
match
$p isa perzon;  # error: 'perzon' is not a defined type

At the strict end, consider an attribute with an enumerated set of values, usually modeled with a @values restriction:

#!test[schema, commit]
define

attribute status, value string @values("active", "suspended");
entity account, owns status;

Those values can instead be turned into unary relation types, and the system will validate the enums as types:

#!test[schema, commit]
define

relation account_status @abstract, relates subject;
relation active_status sub account_status;
relation suspended_status sub account_status;

entity account, plays account_status:subject @card(0..1);

Note the @card(0..1) on the plays: the default of @card(0..) would allow an account to be active and suspended at the same time, so we tighten it to match the single-valued status attribute it replaces.

#!test[write, commit, count = 1]
insert
$account isa account;
$active isa active_status, links (subject: $account);

With the enum lifted into the type system, each state can later gain its own attributes and roles (e.g. a suspended_status owning a reason), and queries over states are type checked like any other pattern:

#!test[read, count = 1]
match
$status isa account_status, links (subject: $account);  # polymorphic over all states

How much type safety you need comes down to a trade-off: the size of the schema you are willing to manage, against the rigor with which your reads and writes are validated. More types buy you earlier errors and richer queries; less gets you flexibility.

5. Constraining cardinality

Use the most specific possible cardinality for every capability in your schema. The defaults are permissive:

Table 1. Default capability cardinalities
Capability Default cardinality

owns

@card(0..1)

relates

@card(0..1)

plays

@card(0..)

The only way to encode a required connection is a cardinality with a lower bound of at least one, e.g. @card(1) or @card(1..). Note that @key implies cardinality 1 as well (and therefore cannot be combined with @card).

For most relations, tighten the default @card(0..1) on each relates role to @card(1), unless the role is genuinely optional (an account holding without an account is rarely meaningful):

#!test[schema, commit]
define

relation account_holding,
    relates holder @card(1),
    relates account @card(1);

person plays account_holding:holder;
account plays account_holding:account;

A complete account holding commits fine:

#!test[write, commit, count = 1]
match
$person isa person, has name "Alice";
insert
$account isa account;
$holding isa account_holding,
    links (holder: $person, account: $account);

A holding missing its account now fails commit-time validation instead of silently persisting incomplete data:

#!test[write, fail_at = commit]
match
$person isa person, has name "Alice";
insert
$holding isa account_holding, links (holder: $person);  # no account: rejected at commit

The same applies to owns: @card(1) makes an attribute mandatory, @card(1..) requires at least one, and @key both requires exactly one and enforces uniqueness.

6. Constraining values

Value restrictions like @values and @range can be declared on the attribute definition itself, where they constrain every use of the attribute; or locally on an individual owns, where they constrain only that owner. Declaring them at the owns level lets a single generic attribute carry different restrictions for different owners:

#!test[schema, commit]
define

attribute rating, value integer;

entity restaurant, owns rating @range(1..5);
entity movie, owns rating @range(1..10);

Both owners share the same rating attribute (enabling polymorphic queries over ratings), but each is validated against its own range, and a violation is rejected as soon as the insert runs:

#!test[write, commit, count = 1]
insert
$movie isa movie, has rating 9;  # valid for movie
#!test[write, fail_at = runtime]
insert
$restaurant isa restaurant, has rating 9;  # out of range for restaurant: rejected

Subtypes can add their own specializations of inherited capabilities, and both the local and the inherited constraints are validated at commit time. For example, if an identity is required to have at least one email, a subtype service_account can restrict that to exactly one:

#!test[schema, commit]
define

attribute email, value string;

entity identity @abstract,
    owns email @card(1..);

entity user sub identity;

entity service_account sub identity,
    owns email @card(1);

A user only carries the inherited @card(1..), so multiple emails are fine:

#!test[write, commit, count = 1]
insert
$user isa user,
    has email "alice@typedb.com",
    has email "alice@example.com";

A service_account is checked against both the inherited @card(1..) and its local @card(1), so two emails and zero emails are both rejected:

#!test[write, fail_at = commit]
insert
$bot isa service_account,
    has email "bot@typedb.com",
    has email "bot-backup@typedb.com";  # two emails: violates local @card(1)
#!test[write, fail_at = commit]
insert
$bot isa service_account;  # no email: violates both the inherited @card(1..) and the local @card(1)

Under the hood, @key is a composition of @unique and @card(1): @unique requires that no two instances of the owner type (including its subtypes) own an attribute of that type with the same value. Both @key and @unique go on the ownership, not on the attribute definition.

7. Constraining sub-relation participants

Role types are inherited as-is: a part_time_employment sub employment inherits the role employment:employee. There is no distinct role part_time_employment:employee; it is the same employment:employee, held by employment and all of its subtypes, so every type that plays it can participate in the sub-relations too.

To restrict who can participate in a sub-relation, rather than letting it accept every player of the inherited role, create a role subtype with as:

#!test[schema, commit]
define

relation part_time_employment sub employment,
    relates part_time_employee as employee;

person plays part_time_employment:part_time_employee;

This essentially creates a new role type part_time_employment:part_time_employee sub employment:employee, and also blocks the inherited employee role in part_time_employment instances. Note that plays declarations are not inherited across the specialization; players of the new role need their own plays, and it is exactly this that constrains participation. Here both roles happen to be granted to person; in a real model, the specialized role is typically granted to a narrower type: only admins may execute privileged actions, while every user may execute ordinary ones.

#!test[write, commit, count = 1]
match
$company isa company, has name "TypeDB";
insert
$person isa person, has name "Bob";
$employment isa part_time_employment,
    links (employer: $company, part_time_employee: $person);

Because of the role subtyping, querying the parent role at read time also resolves players of the specialized role:

#!test[read, count = 2]
match
$employment isa employment, links (employee: $x);  # Alice (employee) and Bob (part_time_employee)

The inherited employee role, however, can no longer be used in part_time_employment instances; the specialization blocks it:

#!test[write, fail_at = runtime]
match
$person isa person, has name "Bob";
$company isa company, has name "TypeDB";
insert
$employment isa part_time_employment,
    links (employer: $company, employee: $person);  # blocked: use part_time_employee

The cost of a specialized role is that a new name must be created for it.

8. Using functions

Functions abstract read queries into named, reusable units. Functions have several uses: modularizing queries, deriving conceptual relations, and performing aggregation and recursion.

8.1. Modularizing queries

Any pattern repeated across queries can be defined once as a function and called anywhere. Here, we define how to look up people by name, then call the function inside a larger match as a sub-query:

#!test[schema, commit]
define

fun persons_by_name($name: string) -> { person }:
    match
        $person isa person, has name == $name;
    return { $person };
#!test[read, count = 1]
match
let $person in persons_by_name("Alice");
$employment isa employment, links (employee: $person);  # Alice's employment at TypeDB

Judicious use of functions helps minimize code duplication and optimizes for readability and maintainability, especially in larger schemas.

8.2. Deriving conceptual relations

Use a function to compute facts that are fully derivable from stored data. It can be queried as if it were a relation. In this example, we define a function that represents colleagues in companies:

#!test[schema, commit]
define

fun colleagues($person: person) -> { person }:
    match
        $_ isa employment, links (employer: $company, employee: $person);
        $_ isa employment, links (employer: $company, employee: $colleague);
        not { $colleague is $person; };
    return { $colleague };
#!test[read, count = 1]
match
$alice isa person, has name "Alice";
let $colleague in colleagues($alice);  # Bob, via the shared employer TypeDB

We never store the fact that two employees are colleagues; it is derived on the fly from pairs of employment relations that have the same employer.

8.3. Aggregation and recursion

Functions also perform aggregation (e.g. counting a company’s employees with return count($employee);) and recursive traversal of structures of unknown depth (e.g. collecting a manager’s transitive reports), with cycle-safe termination via tabling.

These applications of functions are equally valid in a schema function or in a query-level function (essentially a subquery). Full examples can be found at Queries as Functions.

Further reading

The full semantics of cardinality and value constraints

Reference for all schema annotations

Function signatures, streams, aggregates, and recursion in depth