Skip to content

TypeScript defineContract loses scalar, relation, aggregate, and prepared-parameter types in v8 rc.11 #30341

Description

@sam-goodwin

TypeScript-authored contracts lose metadata when passed directly to postgres({ contract }) in @prisma/orm-postgres@8.0.0-rc.11. This affects scalar results and filters, relation cardinality, aggregate availability, and prepared scalar parameters. The reproduction below imports only Prisma; it does not use Alchemy, Effect, emitted contract declarations, or a database connection.

Reproduction

Install @prisma/orm-postgres@8.0.0-rc.11 and typescript@5.9.3 in an ESM package, save this as repro.ts, and run:

npx tsc --noEmit --strict --module nodenext --target esnext --skipLibCheck repro.ts
import { defineContract } from "@prisma/orm-postgres/contract-builder";
import postgres from "@prisma/orm-postgres/runtime";

const contract = defineContract({ extensions: {} }, ({ field, model, rel }) => {
  const User = model("User", {
    fields: {
      id: field.int().id(),
      email: field.text(),
      name: field.text().optional(),
    },
  });
  const Post = model("Post", {
    fields: { id: field.int().id(), authorId: field.int() },
    relations: {
      author: rel.belongsTo(User, { from: "authorId", to: "id" }),
    },
  });
  return {
    models: {
      User: User.relations({ posts: rel.hasMany(Post, { by: "authorId" }) }),
      Post,
    },
  };
});

export async function check(db: ReturnType<typeof postgres<typeof contract>>) {
  const user = await db.orm.public.User.first();
  if (user) {
    const id: number = user.id;
    const email: string = user.email;
    const name: string | null = user.name;
  }

  const post = await db.orm.public.Post.include("author").first();
  if (post) {
    const email: string = post.author.email;
  }

  await db.prepare({ email: "pg/text@1" }, (sql, params) =>
    sql.public.User.select("id", "email")
      .where((fields, fns) => fns.eq(fields.email, params.email))
      .build(),
  );

  await db.orm.public.User.aggregate((aggregate) => ({
    total: aggregate.count(),
  }));

  // @ts-expect-error a numeric field must reject a string filter
  db.orm.public.User.where({ id: "not-a-number" });
}

Expected

  • Scalar row properties are number, string, and string | null.
  • The required belongsTo relation produces one author, not an array.
  • aggregate.count() is available for the Postgres target.
  • A scalar text column can be compared with a scalar prepared text parameter.
  • The incorrect numeric filter consumes @ts-expect-error.

Actual

  • Scalar row properties are unknown.
  • The author is typed as DefaultModelRow<...>[], so post.author.email fails.
  • Aggregation receives AggregateOperationsUnavailable.
  • The prepared comparison reports incompatible scalar/list expressions (many: true versus many?: undefined).
  • The incorrect numeric filter is accepted (Unused '@ts-expect-error' directive).

Declaration details

The published SQL contract-builder declarations appear to contribute several independent losses:

  • BuiltModels maps every relation to the broad ContractRelation instead of retaining the authored target, cardinality, and nullability.
  • SqlContractResult.domain.namespaces is Readonly<Record<string, BuiltDomainNamespace<Definition>>>, losing literal namespace keys.
  • The returned TypeMaps omits its aggregate metadata argument, defaulting to Record<string, never>.
  • FieldManyOf checks FieldState extends { readonly many?: true }. Scalar state has many?: undefined, which also satisfies that check when exactOptionalPropertyTypes is disabled. Scalar field maps consequently acquire an array dimension. This should distinguish the value true from an absent/undefined flag.

There are related authoring metadata losses: .default()/.defaultSql() return the same state type; model namespace literals are widened to string; preset execution defaults are omitted from field state. These complicate correctly inferred create inputs and non-public namespaces even after fixing the query-facing metadata.

We are adding a local opt-in declaration adapter in Alchemy while retaining Prisma's actual functions and builders at runtime. We would prefer to remove that compatibility layer once native contracts preserve this metadata. Generated declarations remain useful as a parity oracle, but requiring generated application imports would defeat our direct TypeScript-contract integration.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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