How Prisma adapts Result Types based on the Actual Arguments given

You might know Prisma, a collection of products which help to work with databases.
In a nutshell, you give Prisma a database schema and what you get is a bunch of tools to work with your database.

One of these tools is Prisma Client, an "intuitive database client for TypeScript and Node.js", as they state it.
The first time I used Prisma Client, there was something that immediately caught my attention: the way the result type of some APIs react to the input you feed them in.

A short example using Prisma Client #

Imagine we have this simple schema consisting of two models, User and OrgUnit.
Those entities have a 1:n relationship, so a User is assigned to one OrgUnit:

model User {
  id    Int
  email String

  orgUnit OrgUnit
}

model OrgUnit {
  id   Int
  name String
}

The Prisma Client generated from that schema will allow you to query a User by ID.
The interesting thing is that when doing so, you can include its OrgUnit, and that will change the return type of the function call:

const user = await prisma.user.findUniqueOrThrow({
  where: { id: 1 },
});

/*
  Type of the returned `user` contains the fields of the schema model `User`:
    {
      id: number;
      email: string;
    }
 */

const user = await prisma.user.findUniqueOrThrow({
  where: { id: 1 },
  include: { orgUnit: true }, // <-- including the `orgUnit` here
});

/*
  Type of the returned `user` now includes the `orgUnit` with its properties!
    {
      id: number;
      email: string;
    } & {
      orgUnit: {
        id: number;
        name: string;
      };
    }
 */

The main thing to note here is that

  • only when include: { orgUnit: true } is added to the function argument
  • orgUnit with all of its properties will be present in the return type.

This can get pretty wild; Prisma allows you to nest such include's, so you can perform "deep" reads from your database, with multiple layers of joins.
Also, you can use select to get only a subset of the properties of the OrgUnit.
And all the time the result type will reflect exactly what you will get from the database!

const user = await prisma.user.findUniqueOrThrow({
  where: { id: 1 },
  include: {
    orgUnit: {
      // still including `orgUnit`...
      select: { name: true }, // ...but only select `name` here
    },
  },
});

/*
  Type of the returned `user` is:
    {
      id: number;
      email: string;
    } & {
      orgUnit: {
        name: string; // <-- no `id` here anymore!
      };
    }
 */

Prisma is actively analyzing the function argument and adapting the shape of the return type based on that.

The goal of this blog post is to understand the main concept of how to achieve such behavior.
As you will see, it boils down to

  1. Capturing the type of the actual argument with a generic variable.
  2. Passing that generic variable to the result type.
  3. Introspecting the generic variable to adapt the result type accordingly.

We will now build our own version of findUniqueOrThrow demonstrating this concept! For the sake of simplicity, we will only focus on changing the return type depending on whether include is given or not.

Our first draft: the "least common denominator" approach #

Imagine we have to build the type definition for findUniqueOrThrow from scratch.
A good start is to type the return type such that orgUnit is optional:

function findUniqueOrThrow(args: FindArgs): Promise<FindResult>;

type FindArgs = {
  where: { id: number };
  include?: { orgUnit?: boolean };
};

type FindResult = {
  id: number;
  email: string;
  orgUnit?: { // <-- typed as optional
    id: number;
    name: string;
  };
};

This is what I would call the "least common denominator" approach: The property orgUnit can, but might not be returned, and to represent both these scenarios, this property is optional in FindResult.

The downside of this approach is that whenever we use that function, we have to check for orgUnit to be present:

const userWithOrgUnit = findUniqueOrThrow({
  where: { id: 1 },
  include: { orgUnit: true },
});

/*
 * We know that `orgUnit` is present here because we included it.
 * But the type does not reflect that - the type of `orgUnit` has `undefined` included.
 * That's why TypeScript will throw an error if we try to access properties of `orgUnit` here.
 * Very annoying...
 *
 * To avoid type errors, we must handle `orgUnit` being `undefined` in some way.
 */
if (!userWithOrgUnit.orgUnit) {
  throw new Error('should not happen');
}
// From here on TypeScript will know that accessing properties of `orgUnit` is safe

Let's now improve that draft step by step.
I will use inline comments to highlight what we change.

Adding generics and conditional types #

The first step is easy: change the function signature from this...

function findUniqueOrThrow(args: FindArgs): Promise<FindResult>;

...to this:

function findUniqueOrThrow<T extends FindArgs>( // <-- a new generic variable `T` (with a constraint so that only `FindArgs` are accepted)
  args: T // <-- using that generic variable for `args`
): Promise<FindResult>;

At this point, the new generic variable T does not really change much.
Still, there is one important consequence: When calling findUniqueOrThrow without specifying T explicitly, TypeScript will apply something called type argument inference. It will infer T from the function parameter args.
And that inference will capture the concrete shape of the actual argument given! That means whether include is present in the function argument will be reflected in the type T.

The second step is to pass T to the result type. Change this...

function findUniqueOrThrow<T extends FindArgs>(
  args: T
): Promise<FindResult>;

type FindResult = {
  id: number;
  email: string;
  orgUnit?: {
    id: number;
    name: string;
  };
};

...to this:

function findUniqueOrThrow<T extends FindArgs>(
  args: T
): Promise<FindResult<T>>; // <-- we pass `T` to `FindResult` here

type FindResult<T extends FindArgs> = { // <-- `FindResult` now also takes `T`, the same way as `findUniqueOrThrow` does
  id: number;
  email: string;
  orgUnit?: {
    id: number;
    name: string;
  };
};

At this point, T is available in the result type FindResult but not used yet.

As a final step, we have to introspect T and check whether it contains include: { orgUnit: true }.

  • If it does, we add the property orgUnit to the result type.
  • If not, we do not add anything.

We can achieve this with a typescript feature called Conditional Types. Conditional types allow us to ask the question "is type A assignable to type B?" and then continue with one of two branches, whether the answer is "yes" or "no", respectively.

Let's apply conditional types. Change FindResult to this:

type FindResult<T extends FindArgs> = {
  id: number;
  email: string;
} & (T extends { include: { orgUnit: true } }
  ? {
      orgUnit: {
        id: number;
        name: string;
      };
    }
  : {});

We start with including { id: number; email: string } in the result type, because these are the properties of the User model, and those properties will be returned for sure.
It is followed by an ampersand & which, in TypeScript, constructs a so-called Intersection Type.
Intersection types allow to add some stuff to existing types.

But what do we add? Well, we want to add the property orgUnit (with its fields) but only if the org unit was included in the original function call.
The conditional type in the code block above does exactly that. Remember, T captures the actual type of the function call, and the conditional type checks if T is assignable to { include: { orgUnit: true } }.
Essentially, what we are asking is: "Was the function invoked with an argument containing { include: { orgUnit: true } }?" If the answer is "yes", we add the property orgUnit to the result type.
Otherwise, we add an empty object - which does not change anything of the result type.

...and that's it! We built our own version of findUniqueOrThrow which will react to the actual argument given, just like Prisma Client does!

One last improvement: strictness of the function parameter type #

There is one minor aspect left we can improve on: remember when we introduced the generic variable above (instead of using FindArgs directly for the function's parameter type), and I said "At this point, the new generic variable T does not really change much."?
Actually, there is one little difference: in the old version, using FindArgs directly, TypeScript will throw an error if we pass an object literal with unknown properties:

function findUniqueOrThrow(args: FindArgs): Promise<FindResult>;

findUniqueOrThrow({
  where: { id: 1 },
  foo: 'bar', // <-- TS error here: "Object literal may only specify known properties, and 'foo' does not exist in type 'FindArgs'"
});

Our new solution, on the other hand, will not lead to a compilation error for unknown properties:

function findUniqueOrThrow<T extends FindArgs>(args: T): Promise<FindResult<T>>;

findUniqueOrThrow({
  where: { id: 1 },
  foo: 'bar', // <-- no TS error here anymore...
});

That's because the only constraint of T is that it extends FindArgs, and this is the case even if unknown properties are added to the object literal.

From my point of view this is not a big problem. TypeScript checks for unknown properties only if you pass an object literal directly, but not if it comes from somewhere else and you pass it as a variable. Thus, we should probably not rely on this check of TypeScript anyways.
Still, there is a solution to get the error for unknown properties back for our generic function parameter. It makes use of a helper type called Subset:

/**
 * Helper type "Subset".
 * @desc From `T` pick properties that exist in `U`. Simple version of Intersection.
 */
type Subset<T, U> = {
  [key in keyof T]: key extends keyof U ? T[key] : never;
};

function findUniqueOrThrow<T extends FindArgs>(
  args: Subset<T, FindArgs> // <-- using `Subset` here
): Promise<FindResult<T>>;

findUniqueOrThrow({
  where: { id: 1 },
  foo: 'bar', // <-- now, we have a TS error again: "Type 'string' is not assignable to type 'never'"
});

The error message is not the same, but at least we have an error again.

But...what should I do with all of that? #

I remember situations in which the type of something was undefined or null and I thought: I know for sure that this thing is defined here, why does TypeScript not know that?
There is always the "solution" to just throw a non-null assertion (the exclamation mark !) at it, but as a good citizen I try to fix the root cause first.
And the concept we learned in this blog post - the combination of generics and conditional types - can be one tool in our toolbelt to improve the typing of APIs, such that we do not have to resort to any of those nasty quick-fixes!

Bonus: a glance at the types of Prisma Client #

Now that we have learned how all of this works, we can for sure understand the actual types Prisma Client uses!
It cannot be that complicated, right?

Well...😅

function findUniqueOrThrow<T extends UserFindUniqueOrThrowArgs>(
  args?: SelectSubset<T, UserFindUniqueOrThrowArgs>,
): CheckSelect<T, Prisma__UserClient<User>, Prisma__UserClient<UserGetPayload<T>>>;

That's a whole bunch of types!

UserGetPayload - the equivalent of our FindResult type - is also far more complicated than our type...

export type UserGetPayload<
  S extends boolean | null | undefined | UserArgs,
  U = keyof S,
> = S extends true
  ? User
  : S extends undefined
    ? never
    : S extends UserArgs | UserFindManyArgs
      ? 'include' extends U
        ? User & {
            [P in TrueKeys<S['include']>]: P extends 'orgUnit'
              ? OrgUnitGetPayload<S['include'][P]>
              : never;
          }
        : 'select' extends U
          ? {
              [P in TrueKeys<S['select']>]: P extends 'orgUnit'
                ? OrgUnitGetPayload<S['select'][P]>
                : P extends keyof User
                  ? User[P]
                  : never;
            }
          : User
      : User;

Turns out that these type definitions do all sorts of analysis and type mapping.
Still, you can see some similarities to our solution. The biggest difference is that UserGetPayload does everything step by step:

  1. Extract the keys of S in U.
  2. Check if include is in U.
  3. If so, extract all keys having type true of that include.
  4. Check if one of those keys is orgUnit.
  5. If so, include OrgUnitGetPayload in the result type (which contains, amongst other things, the properties for the OrgUnit model).

If you are curious, you can clone this repository I made and dig through index.d.ts, the type definitions of Prisma Client (generated based on the schema we worked on in this blog post).

Did you like this blog post?

Great, then let's keep in touch! Follow me on Bluesky, I post about TypeScript, testing and web development in general - and of course about updates on my own blog posts.