What TypeDB's Type System Actually Buys You
Most databases that get filed under "graph" are property graphs. You have nodes, you have edges, both can carry a bag of key-value properties, and the schema is either absent or a set of constraints bolted on afterwards to stop the worst mistakes. It is a flexible model and it works.
TypeDB made a different bet. The schema is a type system, the query language is checked against it, and the things you can declare are richer than "node" and "edge." That difference sounds academic until you try to model something with real structure, at which point it starts doing work for you.
This post is an explanation of the model rather than a tour of a release. Everything here is TypeQL 3, which is the line Layerbase runs (3.12 is the default for new databases). If you learned TypeDB on 2.x, several things below will contradict what you remember, and I will flag those where they come up.
Three kinds of type, not two
A TypeDB schema declares three independent kinds of type:
- Entities are things that exist on their own. A user, a document, a company.
- Relations are things that connect other things, and they are types in their own right, with their own attributes and their own place in a hierarchy.
- Attributes are typed values that entities and relations can own. Crucially, an attribute is a type with a value type, not a property slot on a parent.
Here is a slice of the IAM schema from TypeDB's own benchmark suite, which is a good example because it is real code rather than a tutorial toy:
define
attribute id @abstract;
attribute email sub id, value string;
attribute name sub id, value string;
attribute path sub id, value string;
attribute review-date, value datetime;
attribute size-kb, value integer;Read attribute id @abstract; carefully. That declares an abstract attribute type with no value type of its own, then email, name, and path subtype it and each pick a value type. You can now write a query that matches "anything with an id" and get emails, names, and paths back, or narrow to email and get only those. In a property graph you would express that with a naming convention and hope.
Relations have named roles, and roles can be specialised
This is the part with the biggest practical payoff.
In a property graph an edge has a label and a direction. In TypeDB a relation declares roles, and the types that can fill each role declare that they plays it. A permission relation is not an arrow from A to B, it is a type with a subject role and an access role, and it can own attributes like everyone else:
define
relation membership,
relates base-member,
relates base-parent;
relation group-membership sub membership,
relates group as base-parent,
relates member as base-member;
relation collection-membership sub membership,
relates collection as base-parent,
relates member as base-member;group-membership and collection-membership are both subtypes of membership, and each one specialises the inherited roles: base-parent becomes group in one case and collection in the other. The names read correctly in each context, and the parent relation still exists as a thing you can query.
That matters because a query written against membership matches both subtypes:
match
$m isa membership, links (base-member: $x, base-parent: $p);One query, every kind of membership you have modelled, including the ones you add next quarter. Nobody has to remember to update a UNION or a list of edge labels. TypeDB calls this polymorphic querying and it is the single feature I would point at if someone asked what the type system is for.
Two related bits of syntax worth knowing:
- A relation instance always names its type. The explicit form is
$m isa group-membership, links (group: $g, member: $u);, which is what TypeDB's own test data uses and what I would write. The 2.x habit of leading with a bare role tuple and hanging the type off the end does not survive the move to 3. isamatches subtypes;isa!matches exactly that type and nothing below it. Reach forisa!when polymorphism is the wrong answer.
Ownership and cardinality are declared, then enforced at commit
Types declare what they own and which roles they play:
define
entity subject @abstract,
plays group-membership:member;
entity user sub subject,
owns email @key,
owns name;
entity user-group sub subject,
owns name @key,
plays group-membership:group;Note where plays group-membership:member sits. It is declared once on the abstract subject, and both user and user-group inherit it. A group can be a member of another group without anyone writing a second rule for that case, because the type hierarchy already said so.
@key says the attribute is present exactly once and unique across the type. @unique says unique but not required. @card(0..1) and friends set explicit bounds on how many of something a type can own or how many players a role can take.
The part people miss is that cardinality is validated at commit, not at insert. You can pass through an intermediate state inside a write transaction that violates a cardinality bound, as long as the state you commit does not. That is the right behaviour for a multi-step write, and it is different from a row-level constraint that fires the moment you touch a table. Not everything defers, though: a role the type does not play, or a @key or @unique collision, can fail at the statement that caused it.
Rules are gone. Functions replaced them.
If you used TypeDB 2.x, you wrote inference rules: when { ... } then { ... }, and the reasoner materialised implied facts at query time. TypeDB 3 removed rules entirely. This is the single biggest thing that makes old TypeDB material misleading, and it took me a minute to stop looking for them.
The replacement is functions: named, typed, callable, and recursive. Same schema, same define block. This one comes from the IAM benchmark, where subject is an abstract supertype of both user and user-group, and subject is what plays the member role. That inheritance is exactly what makes the recursion legal: a group can be a member of a group, because a group is a subject.
define
fun has_group_membership($member: subject) -> { user-group }:
match
$group isa user-group;
{
$m isa group-membership, links (group: $group, member: $member);
} or {
$m1 isa group-membership, links (group: $group, member: $intermediate);
let $intermediate in has_group_membership($member);
};
return { $group };Read the signature first: it takes one argument typed as subject and returns a stream of user-group, which is what the braces in -> { user-group } mean. A function returning a single value writes its return type without braces.
The body is a match with a disjunction. Either $member is directly in $group, or $member is in some $intermediate group and that group is in $group, where "is in" is a recursive call to the function being defined. That is transitive closure over a group hierarchy, written declaratively, in the schema, checked by the type system.
Calling it in a query uses let ... in for a stream:
match
$u isa user, has email "ada@example.com";
let $g in has_group_membership($u);
$g has name $group-name;
select $group-name;Compared to rules, functions are better in ways you feel immediately. They have signatures, so a wrong argument type is a compile error and not a query that quietly returns nothing. They are called explicitly at the site that wants them, instead of applying globally to every query whether you wanted the expansion or not. And they compose: a function can call another function, so you build the derived layer of your domain out of named pieces instead of a soup of rules that interact.
Compared to a recursive CTE in SQL, the win is that the recursion is a named schema object your queries share, not something re-pasted into every query that needs it. Compared to a traversal DSL, the win is that it is still declarative: you say what a group membership is, not how to walk edges to find one.
Queries are pipelines
A TypeQL 3 query is a sequence of stages that pass rows to each other, which is closer to a shell pipeline than to a monolithic SELECT:
match
$u isa user, has name $n;
let $g in has_group_membership($u);
sort $n asc;
limit 10;
fetch {
"name": $n,
"group": $g.name
};match produces rows, sort and limit and distinct and offset transform the stream, reduce aggregates it, and fetch shapes the output into JSON. fetch also nests: a subquery inside a fetch block gives you a list of related documents without a join dance, which is the closest thing TypeQL has to a document projection.
Two more stages worth naming:
selectnarrows the columns you carry forward, which matters more than it sounds like when a function is producing a wide stream.given, added in 3.12, binds typed input rows that travel separately from the query string. It is injection-safe by construction and lets you run the same pipeline over many rows in one round trip. I wrote that one up separately in TypeDB 3.12 is now on Layerbase.
What the 3.12 line adds on top of the model
3.12 is worth being on, and mostly for reasons that touch how you work rather than how you model.
@doc("...") and @meta("key", "value") annotations attach documentation and machine-readable metadata to types and to their capabilities. @doc is for the human reading your schema in a year. @meta is readable back through the get_meta() function, so a UI can ask the database how to render a type instead of hardcoding a map on the client.
RocksDB memory became tunable through exposed storage.rocksdb.cache-size and storage.rocksdb.write-buffers-limit settings, which turns "TypeDB used more memory than I expected" from a mystery into a configuration line. Read them as budgets rather than hard ceilings, though: the cache can exceed its size when critical index and filter blocks have to stay resident, and a large commit can temporarily push past the write-buffer limit.
And 3.12.1, the patch we run, fixed two things you would rather not discover yourself: a write deadlock on large commits that exceeded the RocksDB write buffer limit, and a string comparison bug where comparison bounds were applied incorrectly and discarded more answers than they should have. That second one is the nasty kind of bug, because a query that returns too few rows looks like a modelling mistake rather than an engine bug. If you are on an earlier 3.12 build, that alone is a reason to move.
Trying it
TypeDB is on the Layerbase free plan, so creating one costs nothing and the database sleeps when you stop using it. New databases get 3.12; databases already running 3.8 or 3.11 keep running exactly as they are, because we do not move a version underneath a database that is working.
The dashboard has a TypeQL console that renders results in the browser, which is enough to work through the schema above without installing anything. For real work, connect the console or a driver directly, using the host and port from the Quick Connect panel. Leave --password off and the console prompts for it, which keeps the password out of your shell history and out of the process list:
typedb console --address your-host.cloud.layerbase.dev:1729 \
--username admindatabase create iam
transaction schema iam
define
attribute email, value string;
attribute name, value string;
entity user, owns email @key, plays group-membership:member;
entity user-group, owns name @key, plays group-membership:group;
relation group-membership, relates group, relates member;
commit
transaction write iam
insert
$u isa user, has email "ada@example.com";
$g isa user-group, has name "engineering";
$m isa group-membership, links (group: $g, member: $u);
commit
transaction read iam
match $m isa group-membership, links (group: $g, member: $u);
$u has email $e; $g has name $n;
select $e, $n;
closeGetting started with TypeDB walks a full schema end to end if you want a longer runway, and the TypeQL docs are the reference for everything above.
The thing to know is that the type system is not overhead you pay for the privilege of using TypeDB. It is the feature. Once roles are specialised and the derived logic lives in typed functions, a lot of the defensive work you normally do in application code stops being your problem.
Keep reading
- KuzuDB alternatives: a managed graph database without the embedded ceilingKuzu (KuzuDB) is a fast embedded graph database, but with no server and no managed hosting. When to move to a managed graph database, and how TypeDB fits.
- TypeDB 3.12 is Now on LayerbaseTypeDB 3.12 landed this week with a given stage for injection-safe batch queries, @doc and @meta schema annotations, a new bulk loader, and an admin service that is off by default. You can spin one up free on Layerbase in seconds.
- Getting Started with TypeDBBuild a knowledge graph with TypeDB 3.x and TypeQL 3, covering type-safe schemas, pattern matching, multi-hop traversals, and functions that derive facts you never inserted.
- QuestDB 9.4: Time-Series Partitions You Can Store as ParquetQuestDB 9.4 lets a table declare Parquet as its partition format at CREATE TABLE time. Here is what Parquet actually is, why a time-partitioned table is the ideal shape for it, and how the feature behaves on a real database.