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
- Infer Prisma output types without
Prisma.validator
- Create lossless schema validators
- Define a collection of reusable query filters
- Strongly typed functions with inferred return types
- Wrapping up
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 functionconst 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 typetype 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 nameconst countSelect = { _all: true, name: true,} satisfies Prisma.UserCountAggregateInputType;
// Infer the resulting payload typetype 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 usersconst aggregateArgs = { _avg: { profileViews: true, },} satisfies Prisma.UserAggregateArgs;
// Infer the resulting payload typetype 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 countryconst groupByArgs = { by: ["country"], _sum: { profileViews: true, },} satisfies Prisma.UserGroupByArgs;
// Infer the resulting payload typetype 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 theLoaderFunction
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 relatedcomments
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.