payload icon indicating copy to clipboard operation
payload copied to clipboard

feat: `depth` generic, fully type safe result types for relationships and joins

Open r1tsuu opened this issue 1 year ago • 2 comments

This feature allows you to have fully type safe results for relationship / joins fields depending on depth. Currently this is opt-in with typescript.typeSafeDepth: true property, as it may break existing types. Meaning without enabling it - there's no any effect on your existing project. Discussion - https://github.com/payloadcms/payload/discussions/8229

For example, with the given config:

export default buildConfig({
  collections: [
    {
      slug: 'books',
      fields: [
         {
          type: 'relationship',
          name: 'relatedMovie',
          relationTo: 'movies',
        },
        {
          type: 'join',
          on: 'books',
          name: 'author',
          collection: 'authors',
        },
      ],
    },
    {
      slug: 'authors',
      fields: [
        {
          type: 'relationship',
          hasMany: true,
          relationTo: 'books',
          name: 'books',
        },
      ],
    },
    {
      slug: 'movies',
      fields: [
        {
          type: 'relationship',
          relationTo: 'authors',
          name: 'author',
        },
      ],
    },
  ],
  // default in Payload
  defaultDepth: 2,
  typescript: {
    typeSafeDepth: true,
  },
})

Let's try to fetch some movies using the Local API:

Without specifying depth, the result type for each movie is ApplyDepth<Movie, 2>: (From defaultDepth) image

movie.author is ApplyDepth<Author, 1>, without this feature it'd have been annoying string | Author : image

movie.author.books is ApplyDepth<Book, 0>[] because of the hasMany relationship: image

But then, if we try to access movie.author.books[0].relatedMovie we get string which is exactly what we expect from depth: 2: image However, with depth: 3 we get relatedMovie: ApplyDepth<Movie, 0>: image

Now, let's try to specify depth: 0: The result type is ApplyDepth<Movie, 0> and movie.author is string: image

This also works for join fields as well.

Challenges:

  • This works across for any nesting of your relationship fields (including polymorphic ones) to arrays / groups / blocks. To determine whether the current type is a relationship or not, we need some indicator and there's no currently one. This PR adds an internal __collection property (if typescript.typeSafeDepth for collection generated types which the generic uses. https://github.com/payloadcms/payload/blob/a245bf7d7c5988fc12c135566feb898cd0648950/test/relationships/payload-types.ts#L127
  • There's no really an easy way to subtract number types in Typescript like this:
type X = 2 - 1

There are hacks https://softwaremill.com/implementing-advanced-type-level-arithmetic-in-typescript-part-1/ that potentially may screw Typescript Server performance, so in this PR I went with even better approach to generate types for depth specifically: https://github.com/payloadcms/payload/blob/a245bf7d7c5988fc12c135566feb898cd0648950/test/relationships/payload-types.ts#L73-L80

depth.default we use when depth isn't passed, allowed - an enum of allowed depth values. For example we can't pass 11: image And depth.decremented is used for internal typescript purposes which allows us to easily decrement depth for typescript: https://github.com/payloadcms/payload/blob/a245bf7d7c5988fc12c135566feb898cd0648950/packages/payload/src/index.ts#L227-L233

For example DecrementDepth<2> - result 1.

r1tsuu avatar Dec 06 '24 03:12 r1tsuu

I'd be curious if we would benefit from introducing a type assertions tool like tsd or tstyche.

  • https://github.com/tsdjs/tsd
  • https://github.com/tstyche/tstyche

denolfe avatar Dec 06 '24 04:12 denolfe

I'd be curious if we would benefit from introducing a type assertions tool like tsd or tstyche.

  • https://github.com/tsdjs/tsd
  • https://github.com/tstyche/tstyche

We have now type assertions here - https://github.com/payloadcms/payload/pull/9782/files#diff-8dec54cdc3beba0979d4e6eceb4130340bd2ee3bc91f1e779145baaeae8ea440 works perfectly! Here are also assertions that it doesn't break current types https://github.com/payloadcms/payload/pull/9782/files#diff-5e3d49183adb6900b95513d3511004e26e5426bfe5bd555a1609d43cf1321dae (if the feature isn't enabled)

r1tsuu avatar Dec 07 '24 21:12 r1tsuu

@r1tsuu @DanRibbens Sorry for bothering you guys once again, but this feature is absolutely mindblowing. Is there anything I can do to help this feature get shipped in the next update?

V1RE avatar Feb 13 '25 19:02 V1RE

@r1tsuu @DanRibbens Sorry for bothering you guys once again, but this feature is absolutely mindblowing. Is there anything I can do to help this feature get shipped in the next update?

You're not bothering us at all. Thank you for the nudge. I will try and make more time for this soon.

DanRibbens avatar Feb 14 '25 03:02 DanRibbens

Fantastic feature, excited to see it, great job Payload team

robertmalicke avatar Mar 05 '25 01:03 robertmalicke

@r1tsuu Is it possible to somehow start using this inside a project? If not, pkg.pr.new might be interesting so the community can start testing these types of changes

V1RE avatar Mar 11 '25 17:03 V1RE

@DanRibbens By any chance had time to look into this?

V1RE avatar Apr 01 '25 18:04 V1RE

This feature would be massively helpful! 🙏

crcorbett avatar Apr 01 '25 22:04 crcorbett

Any updates on this?

renekahr avatar Jun 25 '25 15:06 renekahr

I’m super stoked for this as well and can’t wait for this feature to be ready. This would dramatically improve my code quality!

ericwaetke avatar Jun 27 '25 08:06 ericwaetke

@ericwaetke Agree! By the way, how do you currently solve this? I can't find a proper way to type my queries that use depth without either writing the types myself or checking for the existence of each field in the code.

renekahr avatar Jun 30 '25 19:06 renekahr

@ericwaetke Agree! By the way, how do you currently solve this? I can't find a proper way to type my queries that use depth without either writing the types myself or checking for the existence of each field in the code.

I’ve got a function like this, pretty sure it’ll work most of the time

async function fetchOrReturnRealValue<T extends keyof Config['collections']>(
	item: number | Config['collections'][T],
	collection: T
): Promise<Config['collections'][T]> {
	if (typeof item === "number") {
		const payload = await getPayload()
		return await payload.findByID({
			collection,
			id: item
		}) as Config['collections'][T]
	} else {
		return item as Config['collections'][T]
	}
}

ericwaetke avatar Aug 06 '25 13:08 ericwaetke

@r1tsuu we should revisit this one.

DanRibbens avatar Oct 08 '25 02:10 DanRibbens