ProjectsmahiRelationships

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Relationships

A relation is declared in two places, and both are required: a marker in the model's attributes interface, and a definition in static override relationships. It is then consumed two ways off a live instance, mirroring Laravel's $post->comments() (the query) versus $post->comments (the loaded value).

import { Model, belongsTo, hasMany } from "@mahiframework/database";
import type { BelongsTo, HasMany } from "@mahiframework/database";
import { User } from "./user.model.js";
import { Comment } from "./comment.model.js";

interface PostAttributes {
  id: number;
  user_id: string;
  title: string;

  author: BelongsTo<User>;      // the marker — drives every type
  comments: HasMany<Comment>;
}

export class Post extends Model<PostAttributes>()({
  table: "posts",
  primaryKey: "id",
}) {
  static override relationships = {   // the definition — drives the runtime
    author: belongsTo(() => User, { foreignKey: "user_id" }),
    comments: hasMany(() => Comment, { foreignKey: "post_id" }),
  };
}
const post = await Post.firstOrFail();

// Query side — a fresh builder scoped to this row. Nothing runs until a terminal.
await post.relations.comments().where("approved", 1).orderBy("created_at").get();
await post.relations.comments().count();
const author = await post.relations.author().first();

// Value side — the LOADED result, populated by with()/load()/loadMissing().
await post.loadMissing("comments");
post.comments;   // Collection<Comment> | undefined
post.author;     // User | undefined

The marker is the type; the definition is the behaviour. Everything static, the loaded value's type, the name-checking on with() and whereHas(), the relations.x() builder's element type, is derived from the marker in PostAttributes. The helper call in relationships carries the runtime key configuration, and the compiler checks the two agree: declaring author: BelongsTo<User> and then pointing the helper at Tag is a compile error, as is omitting a declared name from the map.

The markers are erased at runtime (each is a phantom brand over a unique symbol), so they cost nothing and never appear on an instance. There is no per-model accessor boilerplate to write.

The eleven relation types

MarkerHelperWhere the key livesLoaded valueOptions interface
BelongsTo<R>belongsToThis tableR | undefinedBelongsToOptions
HasOne<R>hasOneRelated tableR | undefinedHasOneOptions
HasMany<R>hasManyRelated tableCollection<R> | undefinedHasManyOptions
BelongsToMany<R, P?>belongsToManyPivot tableCollection<R> | undefinedBelongsToManyOptions
MorphOne<R>morphOneRelated table (type + id)R | undefinedMorphOneOptions
MorphMany<R>morphManyRelated table (type + id)Collection<R> | undefinedMorphManyOptions
MorphTo<R>morphToThis table (type + id)R | undefinedMorphToOptions
MorphToMany<R, P?>morphToManyPivot table (type + id)Collection<R> | undefinedMorphToManyOptions
MorphedByMany<R, P?>morphedByManyPivot table (type + id), inverseCollection<R> | undefinedMorphedByManyOptions
HasOneThrough<R>hasOneThroughVia an intermediateR | undefinedHasOneThroughOptions
HasManyThrough<R>hasManyThroughVia an intermediateCollection<R> | undefinedHasManyThroughOptions

R is the related model's instance type, the class itself (BelongsTo<User>, not BelongsTo<typeof User> or BelongsTo<UserRow>). The optional P on the three many-to-many markers is the pivot-attributes type; supplying it makes the loaded element R & { pivot: P }. See Pivot columns.

Every loaded value includes | undefined, because "not loaded yet" is a real state: only with() narrows it away.

morphTo is declarable, but its query-side accessor is a MorphToBuilder, not an EloquentBuilder, the one relation whose accessor differs, because its target model isn't known until the discriminant is read. It also can't be used with whereHas()/withCount(); both throw, pointing at whereHasMorph().

Declaring relations

The two halves of one relation:

comments: HasMany<Comment>;                                   // in the attributes interface
comments: hasMany(() => Comment, { foreignKey: "post_id" }),  // in `static override relationships`

The override on static override relationships is required, the base model declares the member, and the compiler checks the map you assign against the markers in your attributes interface. A name declared as a marker but missing from the map is a compile error, as is a definition whose helper points at a different class from the one the marker names:

interface PostAttributes {
  author: BelongsTo<User>;
}

static override relationships = {
  author: belongsTo(() => Tag, { foreignKey: "user_id" }),   // ✗ compile error
};

That check is what the redesign buys over the old single-map form: the marker and the definition cannot silently disagree.

belongsTo(() => User, …), never belongsTo(User, …). The thunk is only invoked when a relation actually runs, so two models can declare relations pointing at each other without a circular-import ordering problem at module-evaluation time:

// post.model.ts
static override relationships = {
  author: belongsTo(() => User, { foreignKey: "user_id" }),
};

// user.model.ts
static override relationships = {
  posts: hasMany(() => Post, { foreignKey: "user_id" }),
};

A direct class reference in the argument list would be evaluated at class-definition time and hit a TDZ ReferenceError whichever module the bundler entered the cycle through. The markers themselves are types, so they are erased entirely and never participate in the cycle at all. Self-referential relations work for the same reason:

// interface PostAttributes { parent: BelongsTo<Post>; replies: HasMany<Post>; ... }
parent:  belongsTo(() => Post, { foreignKey: "parent_id" }),
replies: hasMany(() => Post, { foreignKey: "parent_id" }),

No key-name guessing

Every foreign key is named explicitly. There is no "post_id because the parent class is Post" inference, deliberately, matching the framework's stance against name-derived magic elsewhere (the gate registry requires gate.policy(Post, PostPolicy) rather than guessing; the container has no auto-wiring).

The only defaults are the ones that can't be wrong: localKey/ownerKey/relatedKey/secondLocalKey fall back to the relevant model's own primaryKey.

A polymorphic relation's type also defaults, to the declaring model's morphAlias(), but that isn't guessing either. It reads a declared property of the model (a morph-map entry, morphName, or table) rather than deriving a name from another name.

Options by type

belongsTo

The foreign key lives on this model's table.

belongsTo(related, {
  foreignKey: string;    // column on THIS table, e.g. "user_id"
  ownerKey?: string;     // defaults to the related model's primaryKey
});
// interface PostAttributes { author: BelongsTo<User>; ... }
author: belongsTo(() => User, { foreignKey: "user_id" }),

A null foreign key produces WHERE owner_key = NULL, which matches nothing, .first() yields undefined, the same answer Eloquent gives, with no special case.

hasMany / hasOne

The foreign key lives on the related model's table.

hasMany(related, {
  foreignKey: string;   // column on the RELATED table
  localKey?: string;    // defaults to this model's primaryKey
});

// hasOne takes the same options.
// interface PostAttributes { comments: HasMany<Comment>; profile: HasOne<Profile>; ... }
comments: hasMany(() => Comment, { foreignKey: "post_id" }),
profile:  hasOne(() => Profile, { foreignKey: "user_id" }),

hasOne is hasMany. Identical option shape, identical SQL. On the instance side the method is literally return this.hasMany(related, options). The only differences are intent (which the marker and the helper both record), the loaded value's type (R | undefined vs Collection<R> | undefined), and, for the batched loader, whether it calls .first() on the group.

There is no uniqueness enforcement. That's the database's job, via a unique index on the foreign key. If two rows match a hasOne, you get an arbitrary one.

belongsToMany

belongsToMany(related, {
  pivotTable: string;
  foreignPivotKey: string;   // pivot column pointing at THIS model
  relatedPivotKey: string;   // pivot column pointing at the RELATED model
  localKey?: string;         // defaults to this model's primaryKey
  relatedKey?: string;       // defaults to the related model's primaryKey
});
// interface PostAttributes { hashtags: BelongsToMany<Hashtag>; ... }
hashtags: belongsToMany(() => Hashtag, {
  pivotTable: "post_hashtag",
  foreignPivotKey: "post_id",
  relatedPivotKey: "hashtag_id",
}),

It compiles to a subquery, not a join

SELECT * FROM hashtags
WHERE hashtags.id IN (SELECT hashtag_id FROM post_hashtag WHERE post_id = ?)

One query either way, but the subquery form keeps the result rows exactly the related row shape, no pivot columns bleeding in, no ambiguous duplicate column names, and keeps the return value the related model's ordinary builder, so its global scopes and any further chaining work unchanged.

Add withPivot to read pivot columns; that switches the compilation to a join, since the values have to come back with the row.

If the pivot carries enough data to be interesting in its own right, its own relations, its own lifecycle, it isn't a pivot, it's an entity. Give it a Model and use two hops instead:

interface SubscriptionAttributes {
  id: string;
  user_id: string;
  plan_id: string;
  user: BelongsTo<User>;
  plan: BelongsTo<Plan>;
}

export class Subscription extends Model<SubscriptionAttributes>()({
  table: "subscriptions",
  primaryKey: "id",
}) {
  static override relationships = {
    user: belongsTo(() => User, { foreignKey: "user_id" }),
    plan: belongsTo(() => Plan, { foreignKey: "plan_id" }),
  };
}

// On User — `subscriptions: HasMany<Subscription>` in UserAttributes:
subscriptions: hasMany(() => Subscription, { foreignKey: "user_id" }),

That's a modelling choice, not a workaround: pivot rows are perfectly writable through attach()/sync(): the question is only whether the join row deserves a name.

morphOne / morphMany

The owning side. The discriminant and foreign key both live on the related table.

morphMany(related, {
  morphType: string;   // discriminant column on the RELATED table
  morphId: string;     // FK column on the RELATED table
  type?: string;       // defaults to this model's morphAlias()
  localKey?: string;   // defaults to this model's primaryKey
});

// morphOne takes the same options.
// interface PostAttributes { comments: MorphMany<Comment>; ... }
comments: morphMany(() => Comment, {
  morphType: "commentable_type",
  morphId: "commentable_id",
}),

Column names are explicit, no {name}_type / {name}_id guessing, matching every other relation here.

type is the one exception, and only because it isn't a column name. It defaults to the declaring model's morphAlias(), so the discriminant is owned by the model it names rather than restated at every declaration pointing at it. Supply it explicitly to store something else; explicit always wins:

comments: morphMany(() => Comment, {
  morphType: "commentable_type",
  morphId: "commentable_id",
  type: "post",
}),

morphOne is morphMany with a .first(), same as hasOne is hasMany.

morphTo

The inverse side. The discriminant and foreign key live on this table.

morphTo<R>({
  morphType: string;
  morphId: string;
  types?: Record<string, () => ModelClass>;   // discriminant value -> model thunk
  ownerKey?: string;                          // defaults to the resolved model's primaryKey
});

Declare it like any other relation, with two differences: the marker names a union of the possible targets, and the helper takes no related thunk. It points at several models, so there is no single class to name. The union is passed as the helper's explicit type argument instead, which is what lets the compiler check it against the marker:

interface CommentAttributes {
  id: string;
  commentable_type: string;
  commentable_id: string;

  commentable: MorphTo<Post | Video>;
}

static override relationships = {
  commentable: morphTo<Post | Video>({
    morphType: "commentable_type",
    morphId: "commentable_id",
    types: { post: () => Post, video: () => Video },
  }),
};

The explicit morphTo<Post | Video> is required. Without it the helper's related type is inferred as unknown, which doesn't satisfy a MorphTo<Post | Video> marker, a compile error at the class declaration rather than a wrong type downstream.

comment.commentable;                             // Post | Video | undefined (once loaded)
await comment.relations.commentable().first();   // Post | Video | undefined
await Comment.query().with("commentable").get(); // batched

The MorphTo<Post | Video> marker is what makes that a proper TypeScript union rather than a bare Model, something TS expresses much better than PHP's duck-typed morphTo, whose return is always Model.

The types map is optional, and under the marker-driven surface it is purely a runtime concern: omitted, the discriminant resolves through the global morph map instead, and the static type is unchanged because it came from the marker either way.

// interface NoteAttributes { notable: MorphTo<Post | Video>; ... }
notable: morphTo<Post | Video>({ morphType: "notable_type", morphId: "notable_id" }),
// note.notable: Post | Video | undefined — the marker's claim, resolved via the map

The two compose rather than one replacing the other: types is consulted first, the map is the fallback. So a relation can declare the types it cares about locally and still resolve values it doesn't know about.

The trade is worth stating plainly. With types declared, the compiler's union and the resolvable set are the same list, written once. Relying on the map alone leaves the marker an unchecked claim: the map is a runtime registry keyed by arbitrary strings, so nothing stops it returning a class the marker never named, and you'd get a value the type says is impossible. Declare types unless you specifically want central configuration.

Resolves to undefined when either column is null, or when the discriminant resolves through neither source. That matches belongsTo's "missing owner resolves to undefined" behaviour: a *_type value is data, and a stale one should behave like a dangling foreign key rather than throw.

There's also an undeclared form, mirroring how morphMany() relates to a declared morphMany:

const parent = await comment.morphTo({ morphType: "...", morphId: "...", types: { ... } });

MorphToBuilder

comment.relations.commentable() returns a MorphToBuilder, not an EloquentBuilder. This is the only relation whose query-side accessor differs, and it's forced: the target is a type, unknown until the discriminant is read, so there's no TRow to parameterise on and no single table to query.

It is still a real, chainable builder, deliberately not a Promise, which would break the uniform relations.x() call shape:

MethodPurpose
first()Resolve the parent, or undefined
exists()Whether the parent row exists
targetClass()The resolved model class, before querying
constrain(callbacks)Per-type constraining callbacks
morphWith(map)Per-type nested eager loads. See morphWith()
withTrashed() / withoutTrashed() / onlyTrashed()Soft-delete variants
toBuilder()The target's real EloquentBuilder, or undefined

constrain() and morphWith() come from a shared MorphToSpec base that the batched eager loader uses too, so the callback in with({ commentable: (m) => … }) sees exactly this API whether it runs against one row or a whole page. Only first()/exists(), which need a specific parent row, are unique to MorphToBuilder.

Constraints are per type, keyed by discriminant, because there's no column common to the whole union. Post has published, Video may not:

await comment.relations
  .commentable()
  .constrain({
    post:  (q) => q.where("published", 1),
    video: (q) => q.where("visibility", "public"),
  })
  .first();

Types absent from the map are left unconstrained. Use toBuilder() for anything the surface above doesn't cover (count(), a custom builder's scopes). It returns undefined when the discriminant resolves to nothing.

whereHas() and withCount() don't work on morphTo

Both throw, pointing you at whereHasMorph():

whereHas()/withCount() cannot be used on a morphTo relation — its target
table isn't known until each row's discriminant is read. Use
whereHasMorph() instead.

A correlated EXISTS needs one table to correlate against. A morphTo's parents live in several, so the subquery's shape would depend on the data. whereHasMorph() takes the type list explicitly and emits one correlated disjunct per type.

morphToMany / morphedByMany

Polymorphic many-to-many: one pivot table shared by several parent types. The pivot carries a discriminant naming which.

// taggables: tag_id, taggable_id, taggable_type, weight

The two relations read the same pivot from opposite ends:

Declared onDiscriminant namesKey pointing at this model
morphToManythe morphed model (Post)this modelmorphId
morphedByManythe shared model (Tag)the related modelforeignPivotKey
// On Post — "my tags". `tags: MorphToMany<Tag>` in PostAttributes.
tags: morphToMany(() => Tag, {
  pivotTable: "taggables",
  morphType: "taggable_type",   // pivot column: "post" | "video"
  morphId: "taggable_id",       // pivot -> posts.id
  relatedPivotKey: "tag_id",    // pivot -> tags.id
}),

// On Tag — "posts with this tag", same pivot, read backwards.
// `posts: MorphedByMany<Post>` in TagAttributes.
posts: morphedByMany(() => Post, {
  pivotTable: "taggables",
  morphType: "taggable_type",
  morphId: "taggable_id",       // pivot -> posts.id  (the RELATED model)
  foreignPivotKey: "tag_id",    // pivot -> tags.id   (THIS model)
}),

Which side type names is the asymmetry to watch. It's the single easiest thing to get wrong here, because the key has the same name on both interfaces but means opposite things:

  • morphToMany: type is this model's discriminant, defaulting to the declaring model's morphAlias().
  • morphedByMany: type is the related model's, defaulting to the related model's morphAlias().

Laravel hides this behind an internal $inverse flag. It's surfaced in the defaulting rules instead, so the option docs state which side each one means.

Both compile to a subquery filtered by the discriminant:

select * from tags where id in (
  select tag_id from taggables
  where taggable_id = ? and taggable_type = 'post')

That predicate is required: a post and a video with the same id both have rows in taggables, and only taggable_type separates them.

Pivot columns

By default a pivot is pure plumbing. The returned rows are exactly the related model's, with no pivot data readable. Request columns to change that, in both halves of the declaration: withPivot in the options fetches them, and the marker's second type parameter types them.

interface PostAttributes {
  tags: MorphToMany<Tag, { weight: number; created_at: string }>;
}

tags: morphToMany(() => Tag, {
  pivotTable: "taggables",
  morphType: "taggable_type",
  morphId: "taggable_id",
  relatedPivotKey: "tag_id",
  withPivot: ["weight"],
  withTimestamps: true,     // adds created_at / updated_at
}),
const post = await Post.query().with("tags").firstOrFail();
post.tags.first()!.pivot.weight;       // 5 — number
post.tags.first()!.pivot.created_at;

The pivot type rides on the loaded value: a marker with pivot attributes P resolves to Collection<R & { pivot: P }> | undefined, where one without resolves to plain Collection<R> | undefined. The query-side accessor is typed as the related model's ordinary builder, so post.relations.tags().get() yields plain Tag instances as far as the compiler is concerned. The pivot values are still attached at runtime, but reading them off that path needs a cast.

Available on belongsToMany too. It's the same mechanism:

// interface PostAttributes { tags: BelongsToMany<Tag, { weight: number }>; ... }
tags: belongsToMany(() => Tag, {
  pivotTable: "post_tag",
  foreignPivotKey: "post_id",
  relatedPivotKey: "tag_id",
  withPivot: ["weight"],
}),

The two halves are independent: withPivot without the marker parameter fetches values the type doesn't know about, and the marker parameter without withPivot promises values that never arrive. Declare both.

Three things worth knowing:

It switches the compiled query. Without pivot columns the relation is a subquery; with them it's an inner join projecting pivot.{col} as pivot__{col}, because the values have to travel back with the row. Omit withPivot and nothing changes, no join, no extra columns, no behavioural difference for existing callers.

Pivot values are not attributes. They land under a pivot accessor, not in the model's own columns, so tag.toObject() stays exactly the tags table and the values aren't dirty-trackable. A pivot column can't be accidentally written back to the related table on the next save().

The same related row can carry different pivot values. Tag release might be weight 5 on one post and 9 on another. Each attachment therefore gets its own instance rather than a shared one; comparing them by identity across parents won't work.

The pivot__ prefix is what keeps the two tables' column names apart (both may have created_at). It's explicit and documented rather than defended against: a related table with a real pivot__x column would collide, which is a caller error.

hasManyThrough / hasOneThrough

Reaches a distant model via an intermediate one.

hasManyThrough(related, {
  through: () => ModelClass;   // thunk, e.g. () => User
  firstKey: string;            // FK on THROUGH pointing at this model
  secondKey: string;           // FK on RELATED pointing at through
  localKey?: string;           // defaults to this model's primaryKey
  secondLocalKey?: string;     // defaults to the through model's primaryKey
});

// hasOneThrough takes the same options.

A country's posts, via its users:

// interface CountryAttributes { posts: HasManyThrough<Post>; ... }
posts: hasManyThrough(() => Post, {
  through: () => User,
  firstKey: "country_id",   // users.country_id -> countries.id
  secondKey: "user_id",     // posts.user_id    -> users.id
}),

The intermediate model is named only in through. It has no marker of its own, because it never becomes a readable value on this model.

Compiles as a subquery, same reasoning as belongsToMany:

SELECT * FROM posts
WHERE posts.user_id IN (SELECT id FROM users WHERE country_id = ?)

Result rows stay exactly the related model's shape, its global scopes apply, and further chaining works.

Morph maps

A polymorphic column stores a short string naming the model it points at. Model.morphAlias() decides what that string is, resolving in three steps, first match wins:

#SourceUnique?Present by default?
1a Relation.morphMap() entryyesno
2static morphNameyes. ModelRegistry throws on collisionno
3static tableno: two models can share a tableyes
Post.morphAlias();   // "post" if mapped, else morphName, else "posts"

It always resolves, so a model needs no extra declaration to take part in a polymorphic relation.

Laravel's fallback is static::class, which is always unique. There's no equivalent here, a JS class name doesn't survive minification, hence a chain rather than a single fallback.

Registering a map

In a provider's register(), not boot(). The map is class metadata with no container dependencies, and relations may resolve during another provider's boot:

import { Relation } from "@mahiframework/database";

register(): void {
  Relation.morphMap({
    post: () => Post,
    user: () => User,
  });
}

Note the thunks: () => Post, never Post. register() runs at import time, so a bare class reference hits the same TDZ trap related avoids.

Registering pins the stored values, decoupling what's in your database from how your code is named. Without a map, renaming a table silently orphans every existing row that stored the old name.

MethodPurpose
Relation.morphMap(map?, merge?)Register entries, or read the map back with no arguments. Merges by default.
Relation.enforceMorphMap(map, merge?)morphMap() + requireMorphMap() in one call.
Relation.requireMorphMap(require?)Make the map mandatory.
Relation.requiresMorphMap()Whether it currently is.
Relation.getMorphedModel(alias)Alias → class, or undefined.
Relation.getMorphAlias(class)Class → alias, or undefined. The map-only rung.
Relation.resetMorphMap()Clear everything. For test teardown.

Enforcing it

requireMorphMap() disables rungs 2 and 3, so an unregistered model throws ClassMorphViolationError rather than falling back:

Relation.enforceMorphMap({ post: () => Post, user: () => User });

Comment.morphAlias();   // throws — Comment isn't in the map

This is more meaningful here than in Laravel, where it only disables one rung. Worth turning on once you have more than one polymorphic model: adding a third then fails loudly instead of quietly writing a table name into your database.

The map is runtime; the marker is compile-time

Three things resolve a polymorphic target, at two different times:

Resolves atGives you
the MorphTo<Post | Video> markercompile timea precise Post | Video union
a relation's local typesruntimewhich class this relation's discriminant names
Relation.morphMap()runtimethe same, registered centrally

Under the marker-driven surface the union comes from the marker, and both runtime sources feed the same question of which class to instantiate. morphMap() accepts an arbitrary Record<string, () => ModelClass>, so it can't participate in the type at all; a local types map at least sits next to the marker, where the two can be read together and kept honest by eye:

notable: MorphTo<Post | Video>;                          // the type
types: { post: () => Post, video: () => Video }          // the resolution

Laravel can't express the union at all; its morphTo is always Model.

It's process-global

The map is module-level, class metadata, not a service, so it outlives any single Application, exactly as Laravel's static $morphMap does. Two apps in one test process share it. Reset in teardown:

afterEach(() => Relation.resetMorphMap());

Generic helpers

Every relation type also exists as a generic method, on both the class and the instance. The relations namespace is built on top of the instance ones.

Instance form, takes just options, reads keys off the row:

post.belongsTo(User, { foreignKey: "user_id" });
post.hasMany(Comment, { foreignKey: "post_id" });
post.hasOne(Profile, { foreignKey: "user_id" });
post.belongsToMany(Tag, { pivotTable, foreignPivotKey, relatedPivotKey });
post.morphOne(Image, { morphType, morphId, type });
post.morphMany(Comment, { morphType, morphId, type });
await post.morphTo({ morphType, morphId, types });
post.hasManyThrough(Post, { through, firstKey, secondKey });
post.hasOneThrough(Profile, { through, firstKey, secondKey });

Static form, takes a row explicitly:

Post.belongsTo(User, row, { foreignKey: "user_id" });
Post.hasMany(Comment, row, { foreignKey: "post_id" });
// ... same set

All except morphTo return the related model's own builder, its custom builder subclass included, so they chain like any other query and the related model's global scopes apply. Being unnamed, they are outside the marker system: the return is typed as a permissive EloquentBuilder<any>, so results come back untyped and you narrow at the call site.

That imprecision is the point of the trade. Use these only for an ad-hoc, unnamed one-off relation; the declared marker + relationships pair is the intended path, and only declared relations can be eager-loaded, counted, used in whereHas(), or read back with a real type.

Writing relationships

The relations namespace writes as well as reads. Each accessor carries the write methods its relation kind supports and no others. A belongsToMany has attach(), a belongsTo has associate(), and asking for the wrong one is a compile error, not a runtime surprise.

Every column these touch comes from the relation definition, so the no-key-guessing rule holds on the write side too.

Many-to-many: attach / detach / sync / toggle

Available on belongsToMany, morphToMany and morphedByMany.

const post = await Post.findOrFail(id);

await post.relations.tags().attach(tag);              // an instance
await post.relations.tags().attach([1, 2, 3]);        // or keys
await post.relations.tags().attach({ 1: { weight: 9 } });   // with pivot data
await post.relations.tags().attach([1, 2], { source: "import" });  // shared payload

await post.relations.tags().detach([1]);   // just these
await post.relations.tags().detach();      // ALL of them

sync() makes the pivot match a set exactly, and reports what it did in Laravel's three buckets:

// currently [2, 3, 4]
const result = await post.relations.tags().sync([1, 2, 3]);
// { attached: [1], detached: [4], updated: [] }

await post.relations.tags().syncWithoutDetaching([5]);          // add only
await post.relations.tags().syncWithPivotValues([1, 2], { weight: 3 });
await post.relations.tags().toggle([1, 2]);   // flip each
await post.relations.tags().updateExistingPivot(1, { weight: 9 });

sync() and toggle() run in a transaction, joining the caller's via a savepoint when one is open, so a sync() inside your own transaction is rolled back with it rather than committing on its own.

Three behaviours worth pinning down:

  • detach([]) is a no-op, not "detach all". Only the no-argument form detaches everything. This matters: detach(request.input("ids")) with an empty selection must not wipe the relation.
  • attach() does not deduplicate. Attaching an existing link raises the database's own UniqueConstraintViolationException (assuming a unique index, which a pivot should have). Use syncWithoutDetaching() when you mean "ensure linked".
  • updated only ever lists ids you supplied attributes for. A plain sync([1,2,3]) says nothing about pivot payloads, so it never rewrites or reports them.

With withTimestamps: true, attach() stamps created_at/updated_at and updateExistingPivot()/sync() refresh updated_at, spelled the way the connected engine accepts (MySQL rejects the ISO Z form).

Pivot attributes are raw DB-shape values. A pivot table has no model, so there are no casts to apply. Laravel behaves the same way.

The inverse: associate / dissociate

On a belongsTo, and on a morphTo (where both polymorphic columns are written):

post.relations.author().associate(user);
await post.save();

post.relations.author().dissociate();   // nulls the FK
await post.save();

// morphTo sets the discriminant and the id together
comment.relations.commentable().associate(post);
await comment.save();

Neither saves. They set attributes on the parent and hand it back, so several associates and an ordinary field edit share one UPDATE. Both also keep the loaded relation honest: associate() sets it, dissociate() clears it, so post.author never contradicts post.user_id.

A belongsTo accepts a bare key as well as an instance (the loaded relation is then cleared rather than guessed). A morphTo requires an instance. A key alone cannot name the discriminant.

One-to-many: save / create through the relation

On hasOne, hasMany, morphOne and morphMany:

const post = await user.relations.posts().create({ title: "Hello" });
// post.user_id is already set

await user.relations.posts().createMany([{ title: "A" }, { title: "B" }]);
await user.relations.posts().save(existingPost);   // re-parents and saves
await user.relations.posts().saveMany([a, b]);

// morphMany sets the type and the id
await post.relations.comments().create({ body: "Nice" });

create() goes through the related model's own create(), so its timestamps, generated-key read-back and creating/created events all fire as usual.

What stays read-only

hasOneThrough/hasManyThrough have no write methods, in this framework and in Laravel: writing through one would mean inventing the intermediate row, and there is no single correct guess.

Dropping to raw SQL

Still available, and still the right answer for a bulk pivot rewrite or anything the API above doesn't express:

await DB.connection().kysely
  .insertInto("post_hashtag")
  .values({ post_id: post.id, hashtag_id: tag.id })
  .execute();

If the pivot carries enough data to be interesting in its own right, consider modelling it as an entity with its own Model and two belongsTos. See the note under belongsToMany.

Eager loading

with() on a builder

Queues relation names to be batch-loaded when the terminal runs:

const posts = await Post.query().with("author", "images").get();

posts.first()!.author;   // User — no `| undefined`, because it was loaded
posts.first()!.images;   // Collection<PostImage>
posts.first()!.comments; // Collection<Comment> | undefined — NOT loaded

with() only queues the names. The batched queries run once inside get() or first(), after hydration. It has no effect on count(), exists(), or the aggregates.

The names are checked against the declared relations, so a typo is a compile error rather than a runtime one:

Post.query().with("nope");   // ✗ compile error — no relation named "nope"

And the terminal narrows. with("author") removes the | undefined from that one relation, and only that one, the exported Loaded<M, K> type:

const post = await Post.query().with("author").firstOrFail();
// post is Loaded<Post, "author">:
//   post.author   -> User            (guaranteed present)
//   post.comments -> Collection<Comment> | undefined  (untouched)

Loaded is exported, so a function that requires a loaded relation can say so in its signature rather than asserting inside:

import type { Loaded } from "@mahiframework/database";

function byline(post: Loaded<Post, "author">): string {
  return post.author.name;   // no `!`, no cast
}

load() / loadMissing() on an instance

await post.load("author", "comments");
await post.loadMissing("author");   // skips anything already loaded

loadMissing() filters to relations where relationLoaded(name) is false, and returns immediately without querying if nothing is missing, a cheap guard against re-querying what a prior with() already attached.

Unlike with(), these do not narrow the type: they attach onto an existing instance rather than producing a new one, so post.author stays User | undefined afterwards. Use with() when you want the narrowing, and load() when you already have the instance.

loadMany() on an array

import { loadMany } from "@mahiframework/database";

const posts = result.data.toArray();
await loadMany(posts, "author", "images", "hashtags");

Laravel's $collection->load(...). One batched query per relation across the whole array, never one per instance, the N+1-free way to attach relations to a page of rows fetched without with(), which is exactly the situation after cursorPaginate().

It infers the model class from the first instance, and no-ops on an empty array. Note how it reaches the class:

const model = Object.getPrototypeOf(instances[0]!).constructor as ModelClass;

Not instances[0].constructor. A Model instance is a Proxy whose get trap binds every function-valued property, and a bound function loses its statics. See the proxy gotchas in Models.

Being a free function over an array of unrelated instances, loadMany() takes plain string names rather than name-checking against a particular model's markers. An unknown one throws at runtime like any other.

Nested loading with dot paths

A dot path loads a relation of a relation, to any depth:

const post = await Post.firstOrFail();
await post.load("author.team");

post.author.team.name;

On a builder, every segment of a dot path is name-checked and the result is narrowed at each level:

const post = await Post.query().with("comments.author").firstOrFail();
post.comments; // Collection<Comment & { author: User }>

Post.query().with("author.nope"); // compile error: User declares no "nope"

load() and loadMissing() are free functions over already-built instances, so they take plain string paths and report an unknown segment at runtime.

A path stops at a morphTo. The loader resolves a morph node's children per discriminant rather than by path, so with("notable.owner") throws even when every target happens to declare owner, the type rejects it for the same reason. Use morphWith() to nest those.

The cost model is one batched query per relation node, not per row. with("author.team") over 500 posts is 3 queries (posts, authors, teams) and would still be 3 over 500,000, nesting never reintroduces N+1.

A path implies every prefix of itself, and repeated prefixes merge into one node rather than duplicating it:

Post.query().with("author.team", "author.posts");
// ONE authors query, with two children hanging off it — 4 total, not 5.

Merging works across separate with() calls too, so .with("author").with("author.team") is the same three queries as .with("author.team") alone. This matches Laravel's parseWithRelations.

Related instances are de-duplicated before the loader descends, so twenty posts sharing one author issue the teams query against that single author, not twenty copies of it.

An unknown segment names the segment that failed and the model it was missing from, rather than echoing the whole path back:

with("nope"): no relation named "nope" is declared in authors's "static relations".

(The message still says static relations. That's the framework's permanent internal alias for the map you declare as static override relationships. Same map, one runtime spelling.)

load() and loadMissing() take dot paths as well. loadMissing() skips per segment, so on a post that already has its author, loadMissing("author.team") reuses that author and runs only the team query.

The typing depth cap

The dot-path type (RelationPath, and its MaxRelationPathDepth of 5) caps type-checking at five segments ("a.b.c.d.e"). This is a compile-time budget, not a runtime limit, the loader recurses to any depth, and a longer path still works; it just isn't narrowed by the type.

The cap exists because the path type is a recursive union over a model graph that is routinely cyclic (Post.author.posts.author…) and self-referential (Post.replies.replies…), which without a floor never terminates. Five was set from measurement: against a pathological graph, ten models each declaring a relation to all ten, i.e. 100,000 expressible paths, tsc --extendedDiagnostics checked in 1.10s against a 0.87s baseline of the same models with the path type unused. That is 0.25s and roughly 5,300 extra type instantiations to enumerate the entire union; a realistic graph is far below it. The cap is there because unbounded recursion over a cyclic graph is a language-server trap, not because five is near a performance cliff.

Constraining what gets loaded

Pass an object instead of names to narrow a relation as it loads:

const posts = await Post.query()
  .with({ comments: (q) => q.where("approved", 1) })
  .get();

The closure receives the related model's own EloquentBuilder before the batched whereIn runs, the same shape whereHas()'s constraint takes. In with()'s object form the parameter is any, so the columns aren't checked; annotate or cast it if you want them to be:

Post.query().with({
  comments: (q) => (q as unknown as EloquentBuilder<CommentAttributes>).where("approved", 1),
});

Both forms compose, including with dot paths:

Post.query()
  .with({ comments: (q) => q.where("approved", 1) })
  .with("comments.author");

Post.query().with({ "comments.author": (q: any) => q.where("active", 1) });

Two sharp edges worth knowing:

A constraint narrows the relation; it does not error. Filtering rows out makes a to-many attach fewer of them and a to-one attach undefined. That's the same value "no match" produces, so a constraint that accidentally excludes everything looks exactly like an empty relation. Laravel behaves identically.

limit() inside a constraint throws. The relation is fetched for every parent in one batched query, so a LIMIT would cap the whole batch rather than each parent, .limit(3) over 100 posts would return 3 comments in total, while reading at the call site as "3 per post":

with("comments"): limit() isn't supported inside an eager-load constraint.

take()/offset()/skip() throw the same way. Per-parent limits need window functions (ROW_NUMBER() OVER (PARTITION BY …)), which Laravel only gained recently and which aren't implemented here yet. Throwing is deliberate: silently returning the wrong rows is the worse failure, because it looks like it worked.

morphWith(): nested loading per morph type

A dot path can't nest through a morphTo: the segment after the dot has to name a relation on one related model, and a morphTo has several. morphWith() keys the nested loads by discriminant instead:

const notes = await Note.query()
  .with({
    notable: (m) => m.morphWith({
      post: ["comments", "tags"],
      author: ["team"],
    }),
  })
  .get();

A morphTo's constraining callback receives a MorphToSpec rather than a builder, the same constrain()/morphWith() surface MorphToBuilder exposes, so the callback reads identically whether it runs against one row or a whole page. The two compose:

.with({
  notable: (m) => m
    .constrain({ post: (q: any) => q.where("published", 1) })
    .morphWith({ post: ["comments"] }),
})

The per-type callbacks are untyped (q: any). A morph union has no common column set to narrow to, which is the whole reason constraints are keyed by discriminant. Annotate the parameter as the type that branch selects if you want its columns checked.

A type absent from the map loads no children, not an error, since a mixed page routinely holds types you have nothing extra to load for. The keys are the discriminant values from the relation's own types map (or the global morph map) rather than anything the marker names, and morphWith costs no queries beyond the children themselves, because a morphTo already resolves one query per distinct type.

Dot paths work inside morphWith: morphWith({ post: ["comments.author"] }).

What gets batched, and what's attached when empty

Per relation node, across every instance passed in. (A node, not a name: a dot path contributes one node per segment, each batched across everything the previous segment attached.)

TypeQueriesAttached when nothing matches
belongsTo1 (whereIn on the owner key)undefined
hasOne1 (whereIn on the FK)undefined
hasMany1 (whereIn on the FK)empty Collection
morphOne1 (where type + whereIn id)undefined
morphMany1 (where type + whereIn id)empty Collection
morphTo1 per distinct typeundefined
belongsToMany2 (pivot, then related)empty Collection
morphToMany2 (pivot + type filter, then related)empty Collection
morphedByMany2 (pivot + type filter, then related)empty Collection
hasOneThrough2 (through, then related)undefined
hasManyThrough2 (through, then related)empty Collection

morphTo is the one exception to the constant-query-count rule. Its parents live in different tables, so the loader groups the page's rows by discriminant and issues one whereIn per distinct type, same as Laravel. That's bounded by how many types actually appear in the page, not by row count: 500 comments spanning post and video cost two queries, not 500. Rows whose discriminant resolves to nothing cost none.

Parents are keyed by (type, id) internally, since ids are only unique within a type, post 1 and video 1 are different parents.

The distinction matters. A to-many relation is always set to at least an empty Collection, so relationLoaded("comments") is true and post.comments.count() is 0 rather than a TypeError. A to-one relation is set to undefined, which is indistinguishable from "not loaded" by reading the value, use relationLoaded(name) if you need to tell the difference.

That's also why every relation marker resolves to a | undefined type: the two states genuinely share a value for a to-one relation, and only with()'s Loaded narrowing can rule one out.

Foreign-key values are deduplicated before the whereIn, and null and undefined are dropped. If every instance's key is null, the loader attaches the empty value to all of them without running a query at all.

Related rows are hydrated into their own model instances, so post.author is a live User with its own casts, relations and methods, not a bare row.

withCount()

Adds a correlated {name}_count subquery column per named relation:

const posts = await Post.query().withCount("comments", "likes").get();

posts.first()!.comments_count;   // number
posts.first()!.likes_count;      // number

The names are checked against the declared relations, exactly as with()'s are.

For the reads to typecheck, declare the count columns in the attributes interface. A count is an ordinary selected column, so it belongs there like any other:

interface PostAttributes {
  // ...
  comments_count: number;
  likes_count: number;
}

withCount() widens the builder's row type with ${name}_count: number per name, but the instance type comes from the attributes interface, so an undeclared count column reads back through post.getAttribute("comments_count") or a cast instead.

The related model's global scopes apply, so a soft-deleting relation counts only non-trashed rows.

A belongsTo/hasOne count is 0 or 1; a hasMany/belongsToMany count is the full related-row count.

Self-referential relations work

The correlated subquery always selects from {related.table} as {related.table}__sub and qualifies its own side with that alias, leaving the outer side qualified by the parent's real table name:

-- Post.replies is hasMany(() => Post, { foreignKey: "parent_id" })
"posts__sub"."parent_id" = "posts"."id"   -- inner row vs. outer row

So Post.query().withCount("replies") and Post.query().whereHas("replies") behave exactly as they do for any other relation. The alias is applied unconditionally rather than only when the tables collide, so the emitted SQL has one shape to reason about.

Counting a filtered subset

withCount() takes the same object form with() does. The callback receives the related model's builder and narrows the subquery:

const posts = await Post.query()
  .withCount({ comments: (q) => q.where("approved", 1) })
  .get();

posts.first()!.comments_count;   // only approved comments

Unlike with(), the names here are single relations rather than dot paths: the count is a correlated subquery against one related table, and there's no meaningful "count of a nested relation" to project onto the parent row.

Relation existence queries

MethodSQLNotes
whereHas(name, constrain?)WHERE EXISTS (...)
orWhereHas(name, constrain?)OR EXISTS (...)
has(name)WHERE EXISTS (...)whereHas(name) with no constraint
whereDoesntHave(name, constrain?)WHERE NOT EXISTS (...)
orWhereDoesntHave(name, constrain?)OR NOT EXISTS (...)
doesntHave(name)WHERE NOT EXISTS (...)whereDoesntHave(name) with no constraint
await Post.query().whereHas("comments").get();

await Post.query()
  .whereHas("comments", (q) =>
    (q as unknown as EloquentBuilder<CommentAttributes>).where("approved", 1),
  )
  .get();

await User.query().doesntHave("posts").get();

await Post.query()
  .whereHas("images")
  .orWhereHas("hashtags", (q) =>
    (q as unknown as EloquentBuilder<HashtagAttributes>).where("name", "release"),
  )
  .get();

Every name argument is checked against the declared relations, so an undeclared one is a compile error:

Post.query().whereHas("nope");   // ✗ compile error

The constrain callback receives the related model's own EloquentBuilder at runtime, so its scopes and any custom builder methods are available. Its type, though, is EloquentBuilder<RelatedRowOf<…>>, and a helper definition erases its related class to a structural model, so the row type resolves to a permissive bag and no column name typechecks. Route through unknown to name the related model's columns, as above. The cast is a typing convenience, not a behavioural one.

The correlated subquery starts from related.query(), so the related model's global scopes apply, and a whereHas("comments") on a soft-deleting Comment won't match a post whose only comments are trashed. Its table is aliased {table}__sub, which is what makes self-referential relations work (see above).

A name that slips through an untyped path throws immediately rather than silently matching nothing:

comments: no relation named "comments" is declared in posts's "static relations".

Morph-aware existence queries

whereHas() can't work on a morphTo: a correlated EXISTS needs one table to correlate against, and a morphTo's parents live in several. These take the type information explicitly instead.

MethodSQL
whereMorphedTo(name, instance)WHERE type = ? AND id = ?
whereNotMorphedTo(name, instance)WHERE NOT (type = ? AND id = ?)
orWhereMorphedTo / orWhereNotMorphedTothe same, OR-joined
whereHasMorph(name, types, constrain?)OR-group of (type = ? AND EXISTS (...))
orWhereHasMorphthe same, OR-joined
whereDoesntHaveMorph(name, types, constrain?)the negation
orWhereDoesntHaveMorphthe same, OR-joined

whereMorphedTo(): point at one instance

const post = await Post.findOrFail(id);
await Comment.query().whereMorphedTo("commentable", post).get();
// where commentable_type = 'post' and commentable_id = <post.id>

No subquery. Both columns are on the queried table, so this is two plain predicates, the cheapest way to ask "comments on that post", and it works without knowing which types the relation can point at.

The discriminant comes from the instance's own morphAlias(), so it agrees with whatever morphMany/morphToMany would have written.

The negated form brackets the pair, so it means "points at anything but this", not "has a different type but the same id".

whereHasMorph(): parent exists, of a listed type

await Comment.query().whereHasMorph("commentable", [Post, Video]).get();

await Comment.query()
  .whereHasMorph("commentable", [Post], (q) => q.where("published", 1))
  .get();

Each type contributes a disjunct, (discriminant = alias AND EXISTS (correlated subquery)), OR'd together in one group. The grouping is what keeps each type's constraint bound to its own discriminant; without it a video would be matched by the post branch's subquery.

The callback receives the discriminant as a second argument, so one callback can branch per type:

await Comment.query()
  .whereHasMorph("commentable", [Post, Video], (q, type) => {
    if (type === "post") q.where("published", 1);
  })
  .get();

Rows whose parent no longer exists, or whose discriminant isn't in the list, are excluded. Which makes whereDoesntHaveMorph() a way to find orphaned polymorphic rows.

The "*" wildcard

await Comment.query().whereHasMorph("commentable", "*").get();

Expands to every entry in the global morph map, the only enumerable source of "every type this could be". A relation's local types covers just what one declaration named, and neither can see rows storing a discriminant nobody registered.

Throws when the map is empty. An unregistered map would silently match nothing, which is the worse failure.

Name narrowing

These methods narrow name to the model's morphTo relations, so whereMorphedTo("author", …) on a belongsTo is a compile error. The MorphTo marker is what distinguishes the two:

Comment.query().whereMorphedTo("commentable", post);   // ok
Comment.query().whereMorphedTo("author", user);        // ✗ author is a belongsTo

A runtime check backs it up for callers arriving through an untyped path:

whereMorphedTo("author"): "author" is not a morphTo relation.

Type-level details

Useful when writing generic helpers over models. Everything here is exported from @mahiframework/database.

The names you'll actually use:

TypeIs
PostThe model instance type. The class is the type. There is no RowOf/WithRelations wrapper to apply.
Loaded<M, K>M with the relations named by K guaranteed present. What with() returns.
Key<M>M's primary-key value type, read off its id column (string | number when it has none).

Deriving from an attributes map A:

TypeExtracts
RelationKeys<A>The relation-marker names in A.
ColumnKeys<A> / ComputedKeys<A>The plain-column / Computed<> names.
RelationMarkers<A>The relation markers of A, keyed by name.
LoadedValueOf<V>What one marker resolves to, the single source of the to-one/to-many split.
ResolvedAttributes<A>The instance-facing shape: columns as declared, markers as their loaded value, computed as their type.
Relationships<A>The shape static override relationships must have, derived from A's markers.
LoadedRelationValues<A>The value-side accessors, over LoadedValueOf.
RelationBuildersFor<A>The query-side namespace: () => EloquentBuilder<…, Related>, or () => MorphToBuilder<…> for a morphTo.
ModelInstance<A>The base runtime + ResolvedAttributes<A> + the relations namespace.
BuilderFor<A, I>This model's builder, terminating in instance type I.

Deriving from a single relation definition (the runtime map's shape):

TypeExtracts
RelatedRowOf<Def>The related model's row type.
MorphTargetOf<Def>A morphTo's target union, recovered from its types map.
MorphToKeys<R>The names in R whose relation is a morphTo.
RelationValueOf<Def>What one loaded relation holds, by type literal.
RelationWritesFor<Def>The write methods one relation kind adds. unknown for the read-only kinds.
EagerLoadResult<R, K>What with(...K) merges onto the row type, for flat names.
RelationPath<R, D?>Every dot path with() accepts, to MaxRelationPathDepth segments.
NestedEagerLoadResult<R, K>The path-aware form of EagerLoadResult.
MaxRelationPathDepthThe dot-path typing cap (5).
RelationMarker<K, R, ToMany, Pivot, B> / RelationKindThe marker's underlying phantom payload and its kind union.

The removed helpers have no replacement because they have no job left. RowOf<M>, BuilderOf<M> and WithRelations<M> all existed to reassemble a model's real type from pieces scattered across a declaration merge, a declare static Row marker and a relation map. There is now one declaration, the attributes interface, and the class derived from it is the type. Write Post.

The relation markers, precisely

Each marker is a phantom brand carrying five things: the relation kind, the related instance type, whether the loaded value is a collection, the pivot-attributes type, and an optional custom builder type.

BelongsTo<R, B = unknown>              // R | undefined
HasOne<R, B = unknown>                 // R | undefined
HasMany<R, B = unknown>                // Collection<R> | undefined
BelongsToMany<R, Pivot = NoPivot, B = unknown>   // Collection<R & { pivot: Pivot }> | undefined
HasOneThrough<R, B = unknown>          // R | undefined
HasManyThrough<R, B = unknown>         // Collection<R> | undefined
MorphTo<R>                             // R | undefined
MorphOne<R, B = unknown>               // R | undefined
MorphMany<R, B = unknown>              // Collection<R> | undefined
MorphToMany<R, Pivot = NoPivot, B = unknown>     // Collection<R & { pivot: Pivot }> | undefined
MorphedByMany<R, Pivot = NoPivot, B = unknown>   // Collection<R & { pivot: Pivot }> | undefined

The { pivot: P } intersection only appears when Pivot is non-empty, so a many-to-many with no declared pivot attributes resolves to a plain Collection<R> | undefined like any other to-many.

MorphTo is the only marker with no builder parameter, its query-side accessor is a MorphToBuilder, which isn't customisable per relation.

The optional last parameter B names a custom builder for the query-side accessor, which is otherwise the related model's default:

interface PostAttributes {
  comments: HasMany<Comment, CommentBuilder>;
}

post.relations.comments().approved();   // CommentBuilder's own scope, typed

Name-checking, and where it stops

with(), whereHas(), withCount() and the morph-aware methods narrow their name argument to the declared relation names, off Model.query() with no per-model markers:

Post.query().with("nope");                       // compile error
Post.query().whereHas("alsoNope");               // compile error
Comment.query().whereMorphedTo("author", post);  // compile error: author is a belongsTo

The names come from the markers in the attributes interface, the RelationKeys<A> of the map the factory threads into the builder, not from the runtime relationships map. In practice the two agree, because the compiler requires the map to have exactly the marker's keys. The one observable gap: a model that declares markers and omits static relationships entirely still accepts those names at compile time, and throws at runtime.

The narrowing is name-level only. What it does not do is narrow the related model's columns: the constraint callbacks in whereHas() and with({ … }) receive a permissively-typed builder, because a helper definition erases its related class to a structural model. Cast the callback argument when you want the related model's column names checked.

The runtime checks are independent and still fire, an unknown name throws, and whereMorphedTo() on a non-morphTo throws, which is what covers callers arriving through an untyped path.

Because a passing valid name typechecks whether the relation map is the real one or any, the runtime suite cannot detect a regression in the narrowing. Negative type assertions can: each @ts-expect-error fails the build if the line below it ever starts compiling.

What a loaded relation holds

Two types answer that question, on two sides of the same rule.

On the marker side, the one that types your instance, it's LoadedValueOf, which reads the ToMany and Pivot slots off the brand:

V extends RelationMarker<any, infer R, infer ToMany, infer Pivot, any>
  ? ToMany extends true
    ? Collection<WithPivot<R, Pivot>> | undefined
    : R | undefined
  : never

On the definition side, used by the builder's row-type merging, it's RelationValueOf, keyed off the type literal:

Def extends { type: "morphTo" }
  ? MorphTargetOf<Def> | undefined
  : Def extends { type: "belongsTo" | "hasOne" | "morphOne" | "hasOneThrough" }
    ? RelatedInstanceOf<Def> | undefined
    : Collection<RelatedInstanceOf<Def>>

ResolvedAttributes<A> is a one-liner over LoadedValueOf, and EagerLoadResult/NestedEagerLoadResult are one-liners over RelationValueOf, so post.comments and Post.query().with("comments").get() cannot disagree about the shape.

NestedEagerLoadResult extends the same rule down a dot path: it groups requested paths by first segment, so sibling paths under one head merge into a single value type, mirroring the single query the loader issues, and preserves the to-one/to-many shape at each level, so with("comments.author") types post.comments as Collection<Comment & { author: User }> rather than losing either the Collection wrapper or the nested value.

Stepping from one model to the next reads the related instance out of the phantom brand the relation helpers carry (belongsTo(() => User, …) records User in its __brand). The helper's own related thunk is declared () => ModelLike, which erases the concrete class, so the brand is the only place the related type survives, reading it is what lets the walk continue past the first segment. A hand-written definition has no brand but names its class directly in the thunk, so that is used as the fallback and both spellings resolve identically.

RelationDefinition is a union whose morphTo member has no related key. It points at several models, so there's no single class to name. Any code reaching for definition.related() must therefore do so inside a switch that has already excluded morphTo; the compiler enforces it.