Skip to content

bug(query): db.orm.public.<Model>.include() types relations as { [x: string]: unknown } — branded namespace literal breaks index access #890

Description

@hasanaktas

Package and version

@prisma-next/postgres@0.14.0 (and @prisma-next/sql-orm-client@0.14.0)

What happened?

On a namespace-qualified Postgres ORM read (db.orm.public.<Model>), .include('<relation>') resolves the related rows to { [x: string]: unknown }[] instead of the related model's row type. Base scalar reads on the same collection type correctly — only the eager-loaded relation loses its field types. (Runtime data is correct; the breakage is static-types-only.)

What did you expect to happen?

db.orm.public.Author.where(...).include('posts').first() should type posts as the Post row type (e.g. { id: string; title: string; authorId: string }[]), the same way DefaultModelRow<Contract, 'Post'> resolves standalone.

Minimal reproduction

Contract (single public namespace, a 1:N relation):

namespace public {
  model Author {
    id    String @id
    name  String
    posts Post[]
    @@map("author")
  }
  model Post {
    id       String @id
    title    String
    authorId String
    author   Author @relation(fields: [authorId], references: [id])
    @@map("post")
  }
}
import postgres from '@prisma-next/postgres/runtime'
import type { Contract } from './contract'
import contractJson from './contract.json' with { type: 'json' }

const db = postgres<Contract>({ contractJson, url: 'postgresql://USER:PASS@HOST/DB' })

const row = await db.orm.public.Author.where((a) => a.id.eq('x')).include('posts').first()
//    row.posts  →  { [x: string]: unknown }[]        ❌ expected { id: string; title: string; authorId: string }[]

const base = await db.orm.public.Author.where((a) => a.id.eq('x')).first()
//    base.name  →  string                            ✅ base scalars resolve fine

Root cause (traced in @prisma-next/sql-orm-client@0.14.0/dist/index.d.mts)

The include path threads a branded namespace literal into the related-row resolution, and TypeScript rejects a branded intersection as an index key:

  1. db.orm.public correctly sets the collection's State['nsId'] = 'public' (index.d.mts:1292-1295).
  2. .include (index.d.mts:725) computes the related row as InferRootRow<TContract, RelatedName, TargetNs> with TargetNs = RelationTargetNamespace<…, State['nsId']>.
  3. Because nsId is not never, RelationTargetNamespace (index.d.mts:540-544) infers TargetNs from the relation's stored to.namespace.
  4. In the emitted contract.d.ts, to.namespace is 'public' & NamespaceId where NamespaceId = string & { readonly __brand: 'NamespaceId' } — a branded type, not the bare literal 'public'.
  5. NamespaceModelsOf (index.d.mts:324) does NsId extends keyof namespaces ? namespaces[NsId] : …. The branded 'public' & NamespaceId passes extends keyof (assignable to 'public'), but namespaces['public' & NamespaceId] is error TS2538: Type cannot be used as an index type. The indexed access degrades to Record<string, never>, so FieldsOf/ModelDef (index.d.mts:326, 341) are empty and DefaultModelRow's mapped type (index.d.mts:277) collapses to { [x: string]: unknown }.

Verified with type probes against a real contract:

DefaultModelRow<Contract, 'Post', never>                 // ✅ full row (this is why standalone DbDoc works)
DefaultModelRow<Contract, 'Post', 'public'>              // ✅ full row (bare literal)
DefaultModelRow<Contract, 'Post', 'public' & NamespaceId> // ❌ { [x: string]: unknown }  (what include threads)

Base reads work because they resolve at NsId = never (the ScannedModelDef all-namespaces scan, index.d.mts:325), which never hits the branded index access.

Suggested fix

Normalize NsId to a bare key before the indexed access — either strip the brand in RelationTargetNamespace / NamespaceModelsOf (e.g. NsId extends infer K extends keyof namespaces ? namespaces[K] : …, or map the branded literal back to its bare form), or emit to.namespace as a plain string literal rather than 'public' & NamespaceId.

Workaround

Cast the eager-loaded result to a DbDoc<>-derived row type, which resolves correctly at NsId = never:

type AuthorWithPosts = DbDoc<'Author'> & { readonly posts: readonly DbDoc<'Post'>[] }
const row = (await db.orm.public.Author.where((a) => a.id.eq('x')).include('posts').first()) as unknown as AuthorWithPosts | null

A branch refinement (.include('posts', (p) => p.select('id','title','authorId'))) recovers the selected fields' types but still merges in the broken { [x: string]: unknown } index signature, so it is only a partial mitigation.

Environment

  • Node: v22.20.0
  • OS: darwin 26.3
  • Package manager: bun 1.3.14
  • Database: Postgres (target @prisma-next/target-postgres@0.14.0)

Additional context

Surfaced upgrading a per-service multi-database Postgres monorepo from 0.13 → 0.14 (the qualify-flat-builder-accessors change made all reads db.orm.public.<Model>, which is what exposes the branded-namespace include path). Single public schema per service; every .include() call site is affected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions