Skip to content

Official support for composing TypeScript contract #30177

Description

@lonix1

What problem are you trying to solve?

I'm using v8 with TypeScript schemas.

The docs page shows an example schema; here it is, but simplified:

import { defineContract, rel } from "@prisma/orm-postgres/contract-builder";

export default defineContract({}, ({ field, model }) => {

  const User = model("User", {
    fields: {
      id: field.bigint(),
      name: field.text(),
    },
  });

  const Post = model("Post", {
    fields: {
      id: field.bigint(),
      title: field.text(),
      userId: field.bigint(),
    },
  });

  return {
    models: {
      User: User.relations({
        posts: rel.hasMany(Post, { by: "userId" }),
      }).sql({ table: "user" }),
      Post: Post.relations({
        user: rel.belongsTo(User, {
          from: "userId",
          to: "id",
        }),
      }),
    },
  };
});

The documentation states the ts approach is useful when:

model definitions must be split, composed, or reused across ordinary TypeScript modules or packages

I want to do exactly that: split the overall schema into separate files and compose them in the main contract file, for example:

prisma/
  contract.ts
  user.ts
  post.ts

I struggled to get that to work, as there's no guidance for that scenario (other than stating it's possible), and the changes between versions makes it hard to find the correct syntax.

With the help of AI I found two approaches, and both are non-ideal.

Approach 1

prisma/
  contract.ts
  helpers.ts
  modelRegistry.ts
  models/
    user.ts
    post.ts
// helpers.ts

import { defineContract } from "@prisma/orm-postgres/contract-builder";

export type ContractHelpers = Parameters<Parameters<typeof defineContract>[1]>[0];


// modelRegistry.ts

import type * as schemaUser from "./models/user";
import type * as schemaPost from "./models/post";

export interface ModelRegistry {
  User: ReturnType<typeof schemaUser.define>;
  Post: ReturnType<typeof schemaPost.define>;
}


// models/user.ts

import type { ContractHelpers } from "../helpers";
import type { ModelRegistry } from "../modelRegistry";

export function define({ field, model }: ContractHelpers) {
  return model("User", {
    fields: {
      id: field.bigint(),
      name: field.text(),
    },
  });
}

export function configure(
  { User, Post }: ModelRegistry,
  { rel }: ContractHelpers,
) {
  return User.relations({
    posts: rel.hasMany(Post, { by: "userId" }),
  }).sql({ table: "user" });
}


// models/post.ts

import type { ContractHelpers } from "../helpers";
import type { ModelRegistry } from "../modelRegistry";

export function define({ field, model }: ContractHelpers) {
  return model("Post", {
    fields: {
      id: field.bigint(),
      title: field.text(),
      userId: field.bigint(),
    },
  });
}

export function configure(
  { User, Post }: ModelRegistry,
  { rel }: ContractHelpers,
) {
  return Post.relations({
    user: rel.belongsTo(User, { from: "userId", to: "id" }),
  });
}


// contract.ts

import { defineContract } from "@prisma/orm-postgres/contract-builder";
import * as schemaUser from "./models/user";
import * as schemaPost from "./models/post";
import type { ModelRegistry } from "./modelRegistry";

export default defineContract({}, (helpers) => {

  const base: ModelRegistry = {
    User: schemaUser.define(helpers),
    Post: schemaPost.define(helpers),
  };

  return {
    models: {
      User: schemaUser.configure(base, helpers),
      Post: schemaPost.configure(base, helpers),
    },
  };

});

This approach is verbose, and requires the helpers.ts and modelRegistry.ts utilities, the former of which is coupled to an internal implementation detail.

Approach 2

prisma/
  contract.ts
  models/
    user.ts
    post.ts
// models/user.ts

import { field, model, rel } from "@prisma/orm-postgres/contract-builder";
import { PostBase } from "./post";

export const UserBase = model("User", {
  fields: {
    id: field.column({ codecId: "pg/int4@1", nativeType: "int4" }),
    name: field.column({ codecId: "pg/text@1", nativeType: "text" }),
  },
});

export const User = UserBase.relations({
  posts: rel.hasMany(() => PostBase, { by: "userId" }),
});


// models/post.ts

import { field, model, rel } from "@prisma/orm-postgres/contract-builder";
import { UserBase } from "./user";

export const PostBase = model("Post", {
  fields: {
    id: field.column({ codecId: "pg/int4@1", nativeType: "int4" }),
    title: field.column({ codecId: "pg/text@1", nativeType: "text" }),
    userId: field.column({ codecId: "pg/int4@1", nativeType: "int4" }),
  },
});

export const Post = PostBase.relations({
  user: rel.belongsTo(() => UserBase, { from: "userId", to: "id" }),
});


// contract.ts

import { defineContract } from "@prisma/orm-postgres/contract-builder";
import { User } from "./models/user";
import { Post } from "./models/post";

export default defineContract({}, () => ({
  models: { User, Post },
}));

This approach is simpler and cleaner, but lacks the syntax sugar for column definitions; it's too low-level and requires hardcoding magic strings like "pg/int4@1" etc.

Proposed solution

This use case is supposedly supported, but currently there is no official way to actually implement it. Both approaches above are workarounds, not solutions. Please provide an officially supported way to split a contract across files that:

  • uses only exported, public types (no reliance on the library's implementation details)
  • keeps the column-definition syntax sugar (field.text(), field.int(), etc.) rather than requiring error-prone raw codecId/nativeType magic strings
  • doesn't require any

Thank you!

Alternatives considered

No response

Scope and impact

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions