Skip to content

Schemas & Types

TypeGraph’s power comes from its type system. Define your schema once with Zod, and get:

  • Runtime validation on every create and update
  • TypeScript types inferred automatically (no duplication)
  • Query builder constraints that prevent invalid queries at compile time

Nodes represent entities in your graph. Each node has:

  • Type: The type of node (e.g., “Person”, “Company”)
  • ID: A unique identifier within the graph
  • Props: Properties defined by a Zod schema
  • Metadata: Version, timestamps, and soft-delete state
import { z } from "zod";
import { defineNode } from "@nicia-ai/typegraph";
const Person = defineNode("Person", {
schema: z.object({
fullName: z.string().min(1),
email: z.string().email().optional(),
dateOfBirth: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
description: "A person in the system", // Optional
});

TypeGraph supports all Zod validation features:

const Product = defineNode("Product", {
schema: z.object({
// Required string
name: z.string().min(1).max(200),
// Optional with default
status: z.enum(["draft", "active", "archived"]).default("draft"),
// Number with constraints
price: z.number().positive(),
// Array with items validation
categories: z.array(z.string()).min(1),
// Regex pattern
sku: z.string().regex(/^[A-Z]{2,4}-\d{4,8}$/),
// Nullable field
description: z.string().nullable(),
// Transform on validation
slug: z.string().transform((s) => s.toLowerCase().replace(/\s+/g, "-")),
}),
});
// Create with auto-generated ID
const node = await store.nodes.Person.create({ fullName: "Alice Smith" });
// Create with specific ID
const node = await store.nodes.Person.create({ fullName: "Alice Smith" }, { id: "person-alice" });
// Retrieve
const person = await store.nodes.Person.getById("person-alice");
// Update (partial)
const updated = await store.nodes.Person.update("person-alice", {
email: "alice@example.com",
});
// Delete (soft delete by default)
await store.nodes.Person.delete("person-alice");
// Hard delete (permanent removal) - use carefully!
await store.nodes.Person.hardDelete("person-alice");

A node returned from the store has this structure:

const alice = await store.nodes.Person.create({ name: "Alice", email: "a@example.com" });
// alice = {
// id: "01HX...", // Generated ULID (or your custom ID)
// kind: "Person", // The node type name
// name: "Alice", // Schema property (flattened to top level)
// email: "a@example.com", // Schema property
// meta: {
// version: 1,
// createdAt: "2024-01-15T10:30:00.000Z",
// updatedAt: "2024-01-15T10:30:00.000Z",
// deletedAt: undefined,
// validFrom: undefined,
// validTo: undefined,
// }
// }

Schema properties are flattened to the top level for ergonomic access (alice.name instead of alice.props.name). System metadata lives under meta.

By default, delete() performs a soft delete—it sets the deletedAt timestamp but preserves the record:

await store.nodes.Person.delete(alice.id); // Sets deletedAt, keeps the record

For permanent removal, use hardDelete():

await store.nodes.Person.hardDelete(alice.id); // Removes from database

When to use each:

MethodUse Case
delete()Standard deletions, audit trails, undo capability
hardDelete()GDPR erasure, storage cleanup, removing test data

Warning: hardDelete() is irreversible. It also removes associated uniqueness entries and embeddings. Consider using soft delete for most use cases.

Edges represent relationships between nodes. Each edge has:

  • Type: The type of relationship (e.g., “worksAt”, “knows”)
  • ID: A unique identifier
  • From: Source node (type + ID)
  • To: Target node (type + ID)
  • Props: Properties defined by a Zod schema
import { defineEdge } from "@nicia-ai/typegraph";
// Edge with properties
const worksAt = defineEdge("worksAt", {
schema: z.object({
role: z.string(),
startDate: z.string().optional(),
isPrimary: z.boolean().default(true),
}),
});
// Edge without properties
const knows = defineEdge("knows");
// Equivalent to: defineEdge("knows", { schema: z.object({}) })

Edges defined without from and to are unconstrained — they can connect any node type to any node type. When used directly in defineGraph, they are automatically allowed for all node types in the graph:

const sameAs = defineEdge("sameAs");
const related = defineEdge("related", {
schema: z.object({ reason: z.string() }),
});
const graph = defineGraph({
id: "my_graph",
nodes: {
Person: { type: Person },
Company: { type: Company },
},
edges: {
sameAs, // any→any (Person↔Person, Person↔Company, Company↔Company)
related, // any→any, with properties
worksAt: { type: worksAt, from: [Person], to: [Company] }, // constrained
},
});
// All of these work:
await store.edges.sameAs.create(alice, bob, {}); // Person→Person
await store.edges.sameAs.create(alice, acme, {}); // Person→Company
await store.edges.sameAs.create(acme, alice, {}); // Company→Person

This is useful for semantic relationships like sameAs, seeAlso, related, or tagged that apply broadly across node types.

Edges can include built-in domain (source types) and range (target types) constraints directly in their definition. This makes edge definitions self-contained and reusable:

// Edge with built-in domain/range constraints
const worksAt = defineEdge("worksAt", {
schema: z.object({
role: z.string(),
startDate: z.string().optional(),
}),
from: [Person], // Domain: only Person can be the source
to: [Company], // Range: only Company can be the target
});
// Edge connecting multiple types
const mentions = defineEdge("mentions", {
from: [Article, Comment],
to: [Person, Company, Topic],
});

Any edge type can be used directly in defineGraph without an EdgeRegistration wrapper. Constrained edges use their built-in from/to; unconstrained edges allow all node types:

const graph = defineGraph({
nodes: { Person: { type: Person }, Company: { type: Company } },
edges: {
worksAt, // Constrained - uses built-in from/to
sameAs, // Unconstrained - connects any node to any node
},
});

You can still use EdgeRegistration to narrow (but not widen) the constraints:

const worksAt = defineEdge("worksAt", {
from: [Person],
to: [Company, Subsidiary], // Allows both Company and Subsidiary
});
const graph = defineGraph({
edges: {
// Narrow to only Subsidiary targets in this graph
worksAt: { type: worksAt, from: [Person], to: [Subsidiary] },
},
});

Attempting to widen beyond the edge’s built-in constraints throws a ValidationError:

const worksAt = defineEdge("worksAt", {
from: [Person],
to: [Company],
});
// This throws ValidationError - OtherEntity is not in the edge's range
defineGraph({
edges: {
worksAt: { type: worksAt, from: [Person], to: [OtherEntity] },
},
});

Control how many edges can exist:

const graph = defineGraph({
edges: {
// Default: no limit
knows: { type: knows, from: [Person], to: [Person], cardinality: "many" },
// At most one edge of this type from any source node
currentEmployer: {
type: currentEmployer,
from: [Person],
to: [Company],
cardinality: "one",
},
// At most one edge between any (source, target) pair
rated: { type: rated, from: [Person], to: [Product], cardinality: "unique" },
// At most one active edge (valid_to IS NULL) from any source
currentRole: {
type: currentRole,
from: [Person],
to: [Company],
cardinality: "oneActive",
},
},
});
CardinalityDescription
"many"No limit (default)
"one"At most one edge of this type from any source node
"unique"At most one edge between any (source, target) pair
"oneActive"At most one edge with valid_to IS NULL from any source

Cardinality constraints are checked at edge creation time, before the insert:

// With cardinality: "one" on currentEmployer:
await store.edges.currentEmployer.create(alice, acme, {}); // OK
await store.edges.currentEmployer.create(alice, other, {}); // Throws CardinalityError

The check queries existing edges and throws CardinalityError if violated. For oneActive, only edges with validTo unset count toward the limit.

// Create edge - pass nodes directly
const edge = await store.edges.worksAt.create(alice, acme, { role: "Engineer" });
// Retrieve edge
const e = await store.edges.worksAt.getById(edge.id);
// Delete edge
await store.edges.worksAt.delete(edge.id);

The graph definition combines all components:

import { defineGraph } from "@nicia-ai/typegraph";
const graph = defineGraph({
// Unique identifier for this graph
id: "my_application",
// Node registrations
nodes: {
Person: {
type: Person,
onDelete: "restrict", // Default behavior
},
Company: {
type: Company,
onDelete: "cascade",
},
Employment: {
type: Employment,
onDelete: "disconnect",
},
},
// Edge registrations
edges: {
worksAt: {
type: worksAt,
from: [Person],
to: [Company],
cardinality: "many",
},
employedAt: {
type: employedAt,
from: [Company],
to: [Employment],
cardinality: "many",
},
},
// Semantic relationships
ontology: [subClassOf(Company, Organization), disjointWith(Person, Company)],
});

Control what happens when nodes are deleted:

Blocks deletion if any edges are connected:

nodes: {
Author: { type: Author }, // onDelete defaults to "restrict"
}
// This throws RestrictedDeleteError if Author has edges
await store.nodes.Author.delete(authorId);

Automatically deletes all connected edges:

nodes: {
Book: { type: Book, onDelete: "cascade" },
}
// Deletes the book and all edges connected to it
await store.nodes.Book.delete(bookId);

Soft-deletes edges (preserves history):

nodes: {
Review: { type: Review, onDelete: "disconnect" },
}
// Marks connected edges as deleted (deleted_at is set)
await store.nodes.Review.delete(reviewId);

Ensure unique values within node types:

const graph = defineGraph({
nodes: {
Person: {
type: Person,
unique: [
{
name: "person_email",
fields: ["email"],
where: (props) => props.email.isNotNull(),
scope: "kind",
collation: "caseInsensitive",
},
],
},
Company: {
type: Company,
unique: [
{
name: "company_ticker",
fields: ["ticker"],
scope: "kind",
collation: "binary",
},
],
},
},
});
  • "kind": Unique within this exact type only
  • "kindWithSubClasses": Unique across this type and all subclasses
  • "binary": Case-sensitive comparison
  • "caseInsensitive": Case-insensitive comparison

TypeGraph infers TypeScript types from Zod schemas—you never duplicate type definitions.

import { z } from "zod";
import { defineNode, type Node, type NodeProps, type NodeId } from "@nicia-ai/typegraph";
const Person = defineNode("Person", {
schema: z.object({
name: z.string(),
email: z.string().email().optional(),
age: z.number().optional(),
}),
});
// For functions that work with full nodes (id, kind, metadata, props):
type PersonNode = Node<typeof Person>;
// { id: NodeId<Person>; kind: "Person"; name: string; email?: string; version: number; createdAt: Date; ... }
// For functions that only need the property data:
type PersonProps = NodeProps<typeof Person>;
// { name: string; email?: string; age?: number }
// For type-safe node IDs (prevents mixing IDs from different node types):
type PersonId = NodeId<typeof Person>;
// string & { readonly [__nodeId]: typeof Person }

Use Node<typeof X> when your function needs the full node with metadata. Use NodeProps<typeof X> when you only care about the schema properties (e.g., for form validation or API payloads).

// Create returns a fully typed Node
const alice: Node<typeof Person> = await store.nodes.Person.create({
name: "Alice",
email: "alice@example.com",
});
// TypeScript knows the structure
alice.id; // NodeId<typeof Person> - branded string
alice.name; // string
alice.email; // string | undefined
alice.age; // number | undefined
alice.version; // number
alice.createdAt; // Date
// Type errors caught at compile time
await store.nodes.Person.create({
name: 123, // Error: Type 'number' is not assignable to type 'string'
invalid: "field", // Error: Object literal may only specify known properties
});
// Result type is inferred from your select projection
const results = await store
.query()
.from("Person", "p")
.select((ctx) => ({
name: ctx.p.name, // TypeScript knows: string
email: ctx.p.email, // TypeScript knows: string | undefined
id: ctx.p.id, // TypeScript knows: NodeId<Person>
}))
.execute();
// results: Array<{ name: string; email: string | undefined; id: NodeId<Person> }>
// Invalid property access is caught
.select((ctx) => ({
invalid: ctx.p.nonexistent, // TypeScript error!
}))

Edge endpoints are constrained to valid node types:

// Edge definition: worksAt goes from Person → Company
const graph = defineGraph({
// ...
edges: {
worksAt: { type: worksAt, from: [Person], to: [Company] },
},
});
// TypeScript enforces valid endpoints
await store.edges.worksAt.create(alice, acmeCorp, { role: "Engineer" }); // OK
await store.edges.worksAt.create(acmeCorp, alice, { role: "Engineer" });
// Error: Argument of type 'Node<Company>' is not assignable to parameter of type 'Node<Person>'