December 01, 2022

How TypeScript 4.9 `satisfies` Your Prisma Workflows

TypeScript's new satisfies operator allows some new, type-safe patterns that previously required lengthy type annotations or tricky workarounds. This article covers several use cases where it helps you with common Prisma-related workflows.

Table Of Contents

A little background

One of TypeScript's strengths is how it can infer the type of an expression from context. For example, you can declare a variable without a type annotation, and its type will be inferred from the value you assign to it. This is especially useful when the exact type of a value is complex, and explicitly annotating the type would require a lot of duplicate code.

Sometimes, though, explicit type annotations are useful. They can help convey the intent of your code to other developers, and they keep TypeScript errors as close to the actual source of the error as possible.

Consider some code that defines subscription pricing tiers and turns them into strings using the toFixed method on Number:

const plans = {
personal: 10,
team: (users: number) => users * 5,
enterprie: (users: number) => users * 20,
// ^^ Oh no! We have a typo in "enterprise"
};
// We can use `Number` methods on `plans.personal`
const pricingA = plans.personal.toFixed(2);
// We can call `plans.team` as a function
const pricingB = plans.team(10).toFixed(2);
// ERROR: Property 'enterprise' does not exist on type...
const pricingC = plans.enterprise(50).toFixed(2);

If we use an explicit type annotation on plans, we can catch the typo earlier, as well as infer the type of the users arguments. However, we might run into a different problem:

type Plan = "personal" | "team" | "enterprise";
type Pricing = number | ((users: number) => number);
const plans: Record<Plan, Pricing> = {
personal: 10,
team: (users) => users * 5,
// We now catch this error immediately at the source:
// ERROR: 'enterprie' does not exist in type...
enterprie: (users) => users * 20,
};
// ERROR: Property 'toFixed' does not exist on type 'Pricing'.
const pricingA = plans.personal.toFixed(2);
// ERROR: This expression is not callable.
const pricingB = plans.team(10).toFixed(2);

When we use an explicit type annotation, the type gets "widened", and TypeScript can no longer tell which of our plans have flat pricing and which have per-user pricing. Effectively, we have "lost" some information about our application's types.

What we really need is a way to assert that a value is compatible with some broad / reusable type, while letting TypeScript infer a narrower (more specific) type.

Constrained identity functions

Before TypeScript 4.9, a solution to this problem was to use a "constrained identity function". This is a generic, no-op function that takes an argument and a type parameter, ensuring the two are compatible.

An example of this kind of function is the Prisma.validator utility, which also does some extra work to only allow known fields defined in the provided generic type.

Unfortunately, this solution incurs some runtime overhead just to make TypeScript happy at compile time. There must be a better way!

Introducing satisfies

The new satisfies operator gives the same benefits, with no runtime impact, and automatically checks for excess or misspelled properties.

Let's look at what our pricing tiers example might look like in TypeScript 4.9:

type Plan = "personal" | "team" | "enterprise";
type Pricing = number | ((users: number) => number);
const plans = {
personal: 10,
team: (users) => users * 5,
// ERROR: 'enterprie' does not exist in type...
enterprie: (users) => users * 20,
} satisfies Record<Plan, Pricing>;
// No error!
const pricingA = plans.personal.toFixed(2);
// No error!
const pricingB = plans.team(10).toFixed(2);

Now we catch the typo right at the source, but we don't "lose" any information to type widening.

The rest of this article will cover some real situations where you might use satisfies in your Prisma application.

Infer Prisma output types without Prisma.validator

Prisma Client uses generic functions to give you type-safe results. The static types of data returned from client methods match the shape you asked for in a query.

This works great when calling a Prisma method directly with inline arguments:

import { Prisma } from "@prisma/client";
// Fetch specific fields and relations from the database:
const post = await prisma.post.findUnique({
where: { id: 3 },
select: {
title: true,
createdAt: true,
author: {
name: true,
email: true,
},
},
});
// TypeScript knows which fields are available:
console.log(post.author.name);

However, you might run into some pitfalls:

  • If you try to break your query arguments out into smaller objects, type information can get "lost" (widened) and Prisma might not infer the output types correctly.
  • It can be difficult to get a type that represents the output of a specific query.

The satisfies operator can help.

Infer the output type of methods like findMany and create

One of the most common use cases for the satisfies operator with Prisma is to infer the return type of a specific query method like a findUnique — including only the selected fields of a model and its relations.

import { Prisma } from "@prisma/client";
// Create a strongly typed `PostSelect` object with `satisfies`
const postSelect = {
title: true,
createdAt: true,
author: {
name: true,
email: true,
},
} satisfies Prisma.PostSelect;
// Infer the resulting payload type
type MyPostPayload = Prisma.PostGetPayload<{ select: typeof postSelect }>;
// The result type is equivalent to `MyPostPayload | null`
const post = await prisma.post.findUnique({
where: { id: 3 },
select: postSelect,
});

Infer the output type of the count method

Prisma Client's count method allows you to add a select field, in order to count rows with non-null values for specified fields. The return type of this method depends on which fields you specified:

import { Prisma } from "@prisma/client";
// Create a strongly typed `UserCountAggregateInputType` to count all users and users with a non-null name
const countSelect = {
_all: true,
name: true,
} satisfies Prisma.UserCountAggregateInputType;
// Infer the resulting payload type
type MyCountPayload = Prisma.GetScalarType<
typeof countSelect,
Prisma.UserCountAggregateOutputType
>;
// The result type is equivalent to `MyCountPayload`
const count = await prisma.user.count({
select: countSelect,
});

Infer the output type of the aggregate method

We can also get the output shape of the more flexible aggregate method, which lets us get the average, min value, max value, and counts of various model fields:

import { Prisma } from "@prisma/client";
// Create a strongly typed `UserAggregateArgs` to get the average number of profile views for all users
const aggregateArgs = {
_avg: {
profileViews: true,
},
} satisfies Prisma.UserAggregateArgs;
// Infer the resulting payload type
type MyAggregatePayload = Prisma.GetUserAggregateType<typeof aggregateArgs>;
// The result type is equivalent to `MyAggregatePayload`
const aggregate = await prisma.user.aggregate(aggregateArgs);

Infer the output type of the groupBy method

The groupBy method allows you to perform aggregations on groups of model instances. The results will include fields that are used for grouping, as well as the results of aggregating fields. Here's how you can use satisfies to infer the output type:

import { Prisma } from "@prisma/client";
// Create a strongly typed `UserGroupByArgs` to get the sum of profile views for users grouped by country
const groupByArgs = {
by: ["country"],
_sum: {
profileViews: true,
},
} satisfies Prisma.UserGroupByArgs;
// Infer the resulting payload type
type MyGroupByPayload = Awaited<
Prisma.GetUserGroupByPayload<typeof groupByArgs>
>;
// The result type is equivalent to `MyGroupByPayload`
const groups = await prisma.user.groupBy(groupByArgs);

Create lossless schema validators

Schema validation libraries (such as a zod or superstruct) are a good option for sanitizing user input at runtime. Some of these libraries can help you reduce duplicate type definitions by inferring a schema's static type. Sometimes, though, you might want to create a schema validator for an existing TypeScript type (like an input type generated by Prisma).

For example, given a Post type like this in your Prisma schema file:

model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
}

Prisma will generate the following PostCreateInput type:

export type PostCreateInput = {
title: string;
content?: string | null;
published?: boolean;
};

If you try to create a schema with zod that matches this type, you will "lose" some information about the schema object:

const schema: z.ZodType<Prisma.PostCreateInput> = z.object({
title: z.string(),
content: z.string().nullish(),
published: z.boolean().optional(),
});
// We should be able to call methods like `pick` and `omit` on `z.object()` schemas, but we get an error:
// TS Error: Property 'pick' does not exist on type 'ZodType<PostCreateInput, ZodTypeDef, PostCreateInput>'.
const titleOnly = schema.pick({ title: true });

A workaround before TypeScript 4.9 was to create a schemaForType function (a kind of constrained identity function). Now with the satisfies operator, you can create a schema for an existing type, without losing any information about the schema.

Here are some examples for four popular schema validation libraries:

import { Prisma } from "@prisma/client";
import { z } from "zod";
const schema = z.object({
title: z.string(),
content: z.string().nullish(),
published: z.boolean().optional(),
}) satisfies z.ZodType<Prisma.PostCreateInput>;
type Inferred = z.infer<typeof schema>;

Define a collection of reusable query filters

As your application grows, you might use the same filtering logic across many queries. You may want to define some common filters which can be reused and composed into more complex queries.

Some ORMs have built-in ways to do this — for example, you can define model scopes in Ruby on Rails, or create custom queryset methods in Django.

With Prisma, where conditions are object literals and can be composed with AND, OR, and NOT. The satisfies operator gives us a convenient way to define a collection of reusable filters:

const { isPublic, byAuthor, hasRecentComments } = {
isPublic: () => ({
published: true,
deletedAt: null,
}),
byAuthor: (authorId: string) => ({
authorId,
}),
hasRecentComments: (date: Date) => ({
comments: {
some: {
createdAt: { gte: date },
},
},
}),
} satisfies Record<string, (...args: any) => Prisma.PostWhereInput>;
const posts = await prisma.post.findMany({
where: {
AND: [isPublic(), byAuthor(userID), hasRecentComments(yesterday)],
},
});

Strongly typed functions with inferred return types

Sometimes you might want to assert that a function matches a special function signature, such as a React component or a Remix loader function. In cases like Remix loaders, you also want TypeScript to infer the specific shape returned by the function.

Before TypeScript 4.9, it was difficult to achieve both of these at once. With the satisfies operator, we can now ensure a function matches a special function signature without widening its return type.

Let's take a look at an example with a Remix loader that returns some data from Prisma:

import { json, LoaderFunction } from "@remix-run/node";
import invariant from "tiny-invariant";
import { prisma } from "~/db.server";
export const loader = (async ({ params }) => {
invariant(params.slug, "Expected params.slug");
const post = await prisma.post.findUnique({
where: { slug: params.slug },
include: { comments: true },
});
if (post === null) {
throw json("Not Found", { status: 404 });
}
return json({ post });
}) satisfies LoaderFunction;
export default function PostPage() {
const { post } = useLoaderData<typeof loader>();
return (
<article key={post.slug}>
<h1>{post.title}</h1>
<Markdown>{post.body}</Markdown>
<ul>
{post.comments.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
</article>
);
}

Here the satisfies operator does three things:

  • Ensures our loader function is compatible with the LoaderFunction signature from Remix
  • Infers the argument types for our function from the LoaderFunction signature so we don't have to annotate them manually
  • Infers that our function returns a Post object from Prisma, including its related comments

Wrapping up

TypeScript and Prisma make it easy to get type-safe database access in your application. Prisma's API is designed to provide zero-cost type safety, so that in most cases you automatically get strong type checking without having to "opt in", clutter your code with type annotations, or provide generic arguments.

We're excited to see how new TypeScript features like the satisfies operator can help you get better type safety, even in more advanced cases, with minimal type noise. Let us know how you are using Prisma and TypeScript 4.9 by reaching out to us on our Twitter.

Don’t miss the next post!

Sign up for the Prisma Newsletter

Key takeaways from the Discover Data DX virtual event

December 13, 2023

Explore the insights from the Discover Data DX virtual event held on December 7th, 2023. The event brought together industry leaders to discuss the significance and principles of the emerging Data DX category.

Prisma Accelerate now in General Availability

October 26, 2023

Now in General Availability: Dive into Prisma Accelerate, enhancing global database connections with connection pooling and edge caching for fast data access.

Support for Serverless Database Drivers in Prisma ORM Is Now in Preview

October 06, 2023

Prisma is releasing Preview support for serverless database drivers from Neon and PlanetScale. This feature allows Prisma users to leverage the existing database drivers for communication with their database without long-lived TCP connections!