https://t3.gg/blog/posts/types-and-nextjs Theo's Blog An Inconsistent Truth: Next.js and Type Safety December 2, 2021 Imagine a world where Next.js was architected around type safety. "But doesn't Next.js already work with TypeScript?" Yes. I even recommend the Next.js TypeScript template on init.tips. Type safety goes deeper than TypeScript support. What is Type Safety? "...type safety is the extent to which a programming language discourages or prevents type errors" Wikipedia It's important to recognize first and foremost that type safety isn't a boolean 'on/off' state. Type safety is a set of pipes from your furthest off dependency and your user. Throughout my career, I've seen a number of systems that handle types in various ways. For the sake of simplicity, I'm going to over-generalize the structure of a system into a few parts * Data store (SQL, Mongo, Worker KV) * Backend (interface to data store) * API + Schema layer (REST/Swagger, GraphQL, gRPC) * Client (Frontend web app, mobile app, video game) I've been lucky to work primarily in systems where each of these pieces is type safe. At Twitch, we used PostgreSQL for data, Golang for backend, GraphQL for APIs, and React + TypeScript for the front end. Each piece was type safe, and tools like GraphQL allowed us to write a "type contract" between different type systems (in this case a GraphQL schema). Given the separation of concerns and focus, combined with the varied technologies on frontend and backend, this architecture made a lot of sense. Given a full-stack TypeScript app using Next.js, I think we can do much better. Building Better Type Systems Going to start this with a question: When working in a type safe system, should you be writing more types, or less? This question may seem dumb. "Of course you would have more type definitions in the better typed system!" The best type systems should require no types to be written at all. But how??! Type Inference [drake] Credits to Alex for this fantastic meme Writing type definitions for every piece of your code does not make a type safe system. Good type systems are built on top of strongly typed dependencies and models. Type safety comes when the rest is inferred from there. Say I have a model in SQL: model User { id String @id @default(cuid()) name String? } We know, given a User, that we have an id string that is unique and we might have a name that is a string. If we were interfacing with this in TypeScript, the TS definition would look something like type User { id: string; name: string | null; } Here's where the Theo spice comes in: you should never have to write types that look this much like your data models Tools like Prisma serve as a beautiful "translation layer" between your SQL data and your TypeScript backend. export const getUserById = (userId: string) => { return await prisma.user.findFirst({ where: { id: userId } }); }; That's it. The type safety doesn't come from defining our own types. It comes from the source of truth being honored and all further contracts being inferred from that source. Next.js often breaks that contract. Next can be a (type) safety risk This statement is bold, but this problem is large enough to justify it IMO. Know that it comes from a place of love. It is hard to believe that the biggest breach in contract for my type system exists within any given file in the Next.js pages directory, but it's the concern I'm here to shout about. // pages/user-info/[id].ts export default function UserInfo(props) { return
) => Promise>; The P extends bit that auto-assigns a generic object as the return type is...very scary. Way too easy to trigger. IMO, this first arg should be mandatory if this prop is going to be used. After chatting with some folks at Vercel, it's clear they're working to make this better. A lot of the generic export type issues I've laid out here can be sourced back to a more generic export typing issue in TypeScript itself (ty Balazs for pointing me to this). All that said, I think we can work around these problems :) Exploring Outside Of Next Typesafe APIs Before I go too deep here, I should make my bias clear. I'm a tRPC fanboy. tRPC takes full stack type inference to the next level by relying on the types defined in your router as a "schema" on your client. Blitz.js does something similar with queries. Both wrap React Query with typesafe definitions at the API level, which enables some "magic" with type consistency. While this example uses Next, tRPC does not require you use it. It doesn't even require React. Any typescript server and client can serve and consume a tRPC router // pages/api/trpc/[trpc].ts const appRouter = trpc.router().query("get-user-by-id", { input: z.object({ userId: z.string(), }), async resolve({ input }) { const user = await prisma.user.findFirst({ where: { id: input.userId } }); return { user }; }, }); export type AppRouter = typeof appRouter; // pages/user/[id].ts import trpc from "../utils/trpc"; export default function UserInfo() { const { query } = useRouter(); // trpc.useQuery will call "get-user-by-id" api with {userId: query.id} const { data } = trpc.useQuery(["get-user-by-id", { userId: query.id }]); if (!data) return Error: user not found; returnHello {data.user?.name}; } It's important to note that the trpc.useQuery call is as close to 100% typesafe as you can get (hell, even in this case it will type error because query.id isn't guaranteed to exist). The "get-user-by-id" string will auto-complete, and type error if it is not a real query in your tRPC router. The input will error if it doesn't match the zod schema in your query/mutation. The data is typed identically to the return types of your resolve function (even if you use Map and Date, superjson can convert those too). Also - unlike the earlier example, this one can also work with SSR. Server Components This is the React 18 solution. "Just call the backend code in the component". // components/user.server.tsx export const UserInfo: React.FC<{ userId: string }> = (props) => { const user = prisma.user.findFirst({ where: { id: props.userId } }); returnHello {user?.name}; }; // pages/user/[id].tsx import { Suspense } from "react"; import { UserInfo } from "../../components/user.server"; export default function UserInfoPage(props) { const { query } = useRouter(); return (Loading...}> ); } Server components are really damn cool. I think they will help significantly reduce the number of places where this problem exists. I also suspect the transition towards server components will take a long while, and important cases like header metadata will be missed unless explicitly SSR'd ahead of time. Server components are the future. What about now? A Proposal: _props.ts I want to preface this with a few things * I'm writing this out of immense love for Vercel and Next.js. This stack is the most productive I've ever felt and I don't suspect that will change any time soon. I'm betting my company on it. * I'm far from a TypeScript expert - especially on the maintainer side. TypeScript is a whole different beast when you are working on libraries that provide generics. Thank you Tanner and KATT for giving me a glimpse into that world. * I have no intention of implementing any of the things I discuss here. I'm very happy with my tRPC + Next setup and don't want to move. This is purely theoretical. All that said, hear me out. Lightly Inspired Think of this ergonomically as an in-between of the new Next.js Middleware syntax of _middleware.ts and the philosophy behind Blitz.js query resolvers. // pages/user-info/_props.ts export async function getServerSideProps(context) { const id = context.params.id; const user = await prisma.user.findFirst({ where: { id: id } }); return { props: { user } }; } // pages/user-info/[id].ts import Props from "./_props"; // This will have to be some wizardry or a compile step // This should lint error if the type was assigned // to something other than _props in the same dir export default function UserInfo(props: Props) { return Hello {props.user?.name}; } This is a very rough sketch of what I have in mind. My "general thought" is a file-level barrier between "the thing run on the server" and "the thing run on server AND client", with an implicit type contract (potentially generated) through the creation of these files. Could even spit out a useServerSideProps hook Under the hood I would expect this to use something similar to the AsyncInferType example earlier. I can see potential ways to extend this further, such as additional keys you can return or other named files i.e. _dynamicProps.ts or _staticProps.ts. Generally, I like the idea of "files with an underscore run on the server", and that thought brought me here. I think it can go really far, especially when combined with a compiler. Not many other companies are in a position to change all the pieces to build something like this. It's been proven that full stack type inference is possible with modern TypeScript tooling. Let's work towards a future where that's the default Thank You This was a long one. I know it may seem harsh towards Next and Vercel, but that was not my intent at all. I'm critical out of love. I would never have written this much about something I didn't intend to use for years. I bet my company on this stack. I feel like we're working in a stack from the future. Want to shout out a bunch of people who gave feedback on this article, I would have looked way stupider without y'all Shoutout to Alex (tRPC), Balazs (NextAuth.js), Luis (Vercel), Lee (Vercel), Brandon (Blitz.jz), Jacob (CloudFlare), Tanner (TanStack/ React-Query), Jonas (ThirdWeb) and everyone else who I'm forgetting. Extra stuff If you got this far, you might like my rants on Twitter as well. If you want to see this tech in action, check out this 2+ hour deep dive building a full stack app with Prisma, PlanetScale, Next.js, TypeScript, Vercel, tRPC, and Tailwind. <- Back Home