TypeScript Made Easy: A Practical Guide To Your First Typesafe App with NextJS, WunderGraph, and Prisma
It’s time to put your fears aside and finally learn TypeScript. Let’s give you your first “Eureka!” moment by building a full stack Todo App with end-to-end typesafety!
I’ve noticed an interesting sentiment brewing on social media lately. Dissatisfaction that boils down to “All of my favorite educators, content creators, and industry people are shifting to TypeScript-first for their content! How could they?!”
Now, I love TypeScript. For years, it has made React development so much easier for me. But I do see the other side of the argument. In my humble opinion, the cause of this disconnect is not because of lazy JavaScript devs, or that TypeScript is way too difficult (and therefore, unfair to expect anyone getting into [insert tech stack here] to face it as a barrier-to-entry)...but because most people have the wrong idea about TypeScript and type safety to begin with. Let’s clarify:
TypeScript is not a completely different programming language that you need to learn from scratch.
TypeScript is not something that you first need to learn front-to-back to even be useful with.
Building typesafe applications doesn’t mean being back in high school, coding Java, adding return types to everything not bolted down.
This is why the best way to learn TypeScript is by using it as a tool to improve your JavaScript code; not looking up technical specs or taking every online course you can find. To demonstrate how easy this can be, let's build a simple ToDo app in NextJS – something you’ve coded hundreds of times in JavaScript – except this time, we’ll be using TypeScript on both our Server and our Client, and use Prisma and WunderGraph together to ensure end-to-end type safety.
💡 This tutorial assumes you have a basic knowledge of React, state management, and how to build a simple ToDo app using it. A primer on how GraphQL works (and how to write GraphQL queries/mutations) would also help!
How TypeScript Makes You A Better Developer
Before we start, let’s get the obvious question out of the way.
“What’s the catch? Am I back in high school working with Java again? Do I type every single variable and function return I come across?”
Not at all! TypeScript is only a statically-typed superset of JavaScript. You build type safe applications with TypeScript by only adding enough types to get rid of ambiguities in your code that may lead to bugs**, **and then let its built-in type inference do 90% of the work.
What do I mean by that? Let’s say you had a React component that returned the computed result of a function in a <div> (or some other fancy typography thing).
This specific code is a contrived example, but it’s an incredibly common scenario. In a large enough app you’ll often miss it when a function (especially if it’s a third-party library you don’t control) returns a Promise
instead of the primitive you want.
In JavaScript, you’d be able to plow right ahead with the code in App.js, and not even realize anything was wrong until you got a runtime error about how you can’t render a Promise
within a <div>.
If you were coding in TypeScript, though, specifying a shape for your props that explicitly says text is a string (and nothing else) immediately gets you a red squiggly line under <MyComponent text={returnSomeText} />
while coding, that tells you how “Type 'Promise<unknown>' is not assignable to type 'string
'”, preventing your mistake from ever leaving the IDE.
You could then fix it by maybe making the type of text a string | Promise<unknown>
, and having MyComponent return a <div> Loading…</div>
if text
was a Promise
, or the regular <div> {text} </div>
otherwise.
That’s it. You won’t have to manually add explicit return types to every single variable and function. Everything else gets inferred automatically.
There’s that word again, ‘inference’. Let’s explain it quickly. If you had data like:
const data = [
{
id: 1,
title: "hello"
},
{
id: 2,
title: "world"
},
]
Hover your mouse over data
, and you’ll see in the IDE that its type is automatically ‘understood’ by TypeScript as:
const data: { id: number; title: string; }[]
Then, you could do:
const myTitle = data[1].title
myTitle
would automatically be ‘understood’ as a string, and code completion/IntelliSense will list all string operations you could do on it, letting you do:
console.log(myTitle.toUpperCase())
That’s type inference at work. Ambiguity avoided, without ever adding any typing yourself. This is how TypeScript eliminates guesswork so you can code faster. Don’t know which functions/values you can access for something? Let your IDE’s code completion tell you. Want to know the shape of some data when you’re several levels deep in a call stack while debugging? Just hover over, or Ctrl + Click something, and see exactly what it accepts and/or returns.
This is the one and only trick you need to know to get started with TypeScript.** Don’t use it as a programming language. Use it as an advanced linter for JavaScript. **
With that out of the way, let’s get started on our Todo App.
The Code
I’ll be using a local PostgreSQL datasource for this, using Prisma to access it with typesafe APIs, and then WunderGraph to make that data accessible through JSON-RPC to my Next.js frontend where I render it.
Prisma is an ORM (Object Relational Mapping) tool that allows developers to interact with databases using a type-safe API, and WunderGraph is an open-source dev tool that lets you define your data sources as dependencies in config (think a package manager like NPM, but for data!) which it then introspects into a virtual graph that I can write GraphQL queries (or fully custom resolvers written in TypeScript) to get data out of. Then, WunderGraph turns these operations into simple JSON-over-RPC calls.
Combining the two means you can write database queries in a type-safe way without having to deal with SQL, and have them be easily accessible on the Next.js front-end with auto-generated typesafe hooks. All of it in TypeScript for the best developer experience you can ask for.
Step 0A: Setting Up the Database
First off, the easiest way to get a Postgres database going is with Docker, and that’s what I’m doing here. But since these databases use TCP connection strings, you could use literally any Postgres host you want – including DBaaS ones like Railway.
docker run --name mypg -e POSTGRES_USER=myusername -e POSTGRES_PASSWORD=mypassword -p 5432:5432 -d postgres
This’ll set up a Docker Container named ‘mypg’ for you, with the username and password you specify, at port 5432 (localhost), using the official postgres Docker Image (it’ll download that for you if you don’t have it already)
Step 0B: Setting Up WunderGraph + Next.js
Secondly, we can set up both the WunderGraph server and Next.js using WunderGraph’s create-wundergraph-app
CLI, so let’s do just that.
npx create-wundergraph-app my-todos --example nextjs
When that’s done, cd into the directory you just created and npm i && npm start
. Head on over to localhost:3000
and you should see the WunderGraph + Next.js starter splash page pop up with the results of a sample query, meaning everything went well.
Now, it’s time to set up Prisma, using it to create the database schema we want.
Step 1 : Prisma
First, Install the Prisma CLI via npm:
npm install prisma --save-dev
Then create a basic Prisma setup with:
npx prisma init
This’ll create a new prisma
directory, with the config file schema.prisma
in it. This is your schema. Open it up, and modify it like so:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = "postgresql://myusername:mypassword@localhost:5432/postgres"
// …or just put your database URL in .env and access it here, like so.
// url = env("DATABASE_URL")
}
model Todo {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
}
Save this, then run:
npx prisma db push
…to turn this Prisma schema into a database schema, and create the necessary tables in your Postgres database.
If you want, you could now add some dummy data using Prisma’s GUI with:
npx prisma studio
Finally, run:
\> npm install @prisma/client
\> npx prisma generate
… to install the Prisma Client package, and then generate an actual Prisma client. You can then start querying your database.
Step 2 : WunderGraph
Normally, this is the part where you create a lib/prisma.ts
file, with an exported PrismaClient
instance within it, and import it wherever you need to do data fetching. However, using WunderGraph, we can ensure a much, much better developer experience without ever compromising on typesafety.
Check out wundergraph.config.ts
in the .wundergraph
directory in your root, and modify it like so.
wundergraph.config.ts
const prismaDB = introspect.prisma({ apiNamespace: "prisma", prismaFilePath: "../prisma/schema.prisma", introspection: { disableCache: true, }, });
// configureWunderGraph emits the configuration configureWunderGraphApplication({ apis: [prismaDB], ... })
That’s how easy it is. Create a Prisma schema, introspect said schema with WunderGraph, and add it to your project as a dependency array. This ensures your frontend stays decoupled from your data, making maintaining and iterating on your app much, much easier.
Save this config file, and WunderGraph will consolidate this data into a virtual graph. Now you’ll need to define the operations you want to actually get data out of it. WunderGraph makes getting the exact relations you want in one go (as well as cross-source data JOINs!) a cakewalk with GraphQL.
Create an AllTodos.graphql
file in .wundergraph/operations.
AllTodos.graphql
query AllTodos {
todos: prisma_findManyTodo(orderBy: { id: asc }) {
id
title
completed
}
}
This is pretty self-explanatory – it just gets all of our Todos from the database, in ascending order of ID.
Let’s get the rest of our CRUD operations out of the way.
CreateTodo.graphql
mutation createTodo($task: String!) {
prisma_createOneTodo(data: { title: $task }) {
id
}
}
UpdateTodo.graphql
mutation UpdateTodo($id: Int!, $complete: Boolean!){
prisma_updateOneTodo(where: {id: $id}, data: {
completed: {
set: $complete}
}){
id
completed
}
}
DeleteTodo.graphql
mutation DeleteTodo($id: Int!) {
prisma_deleteOneTodo(where: {
id: $id
}){
id
}
}
Now, when you hit save in your IDE, the WunderGraph server will build the types required for the queries and mutations you’ve defined, and generate a custom Next.js client with typesafe hooks you can use in your frontend for those operations.
Step 3 : The NextJS Frontend
So…we’ve gotten all of our backend logic out of the way. What remains is your basic ToDo frontend, and you’d go about building it in TypeScript the exact same as you would with JavaScript. All your muscle memory re: React architecture – components, passing down/lifting up state, event propagation, client vs server state, etc. – can be retained.
Also, I love utility-first CSS, so I’m using Tailwind for styling throughout this tutorial. Install instructions here.
index.tsx
import ToDo from "components/ToDo";
import { NextPage } from "next";
import { useQuery, withWunderGraph } from "../components/generated/nextjs";
const Home: NextPage = () => {
// Using WunderGraph generate hook to get data
// This calls the AllTodos.graphql operation!
const { data } = useQuery({
operationName: "AllTodos",
liveQuery: true, // Subscriptions alternative that needs no WebSockets!
});
return (
<div className="flex items-center justify-center h-screen mx-8 text-white ">
{data ? (
<>
{/* Have half of the screen be the JSON response...*/}
<div className="max-w-1/2 w-full ">
<pre
className="flex items-center justify-center text-slate-800 text-base font-semibold"
>
{JSON.stringify(data, null, 3)}
</pre>
</div>
{/* ...and the other half be our ToDo component */}
<div className="flex items-center justify-center font-mono tracking-tight max-w-1/2 w-full">
<ToDo todos={data.todos} />
</div>
</>
) : (
<>
{/* Something went wrong and we didn't get any data */}
<span className=" font-mono text-2xl">No data found!</span>
</>
)}
</div>
);
};
export default withWunderGraph(Home);
Let’s break down what’s going on here:
Remember those typesafe hooks we talked about?
useQuery
is one such WunderGraph-generated typesafe data fetching hook we’ll be using. Essentially, you calluseQuery
with an options object, specifying –an operation by name (the filename of the GraphQL operation you created in the previous step),
Whether we want this to be a Live Query or not. WunderGraph’s Live Queries are GraphQL Subscription alternatives that don’t need WebSockets (they use HTTP based polling on the Wundergraph server), and so can work on serverless apps.
To change the polling interval for these Live Queries, you can modify the
liveQuery
property inwundergaph.operations.ts.
queries: (config) => ({
…
liveQuery: {
enable: true,
pollingIntervalSeconds: 3,
},
}),
In
data
, we get back the output of said query – an array oftodos
. We don’t have to define any types for this ourselves – WunderGraph already generated every possible type for us when it introspected our Prisma schema!To make it easier for you to see what’s going on, we’re splitting the screen in half. On the left, we render the JSON output of our live query in a <pre> tag (feel free to get rid of this one if you want), and on the right, we render our actual <ToDo> component.
/components/ToDo.tsx
import React, { useState } from "react";
import { useMutation } from "../components/generated/nextjs";
import { AllTodosResponseData } from ".wundergraph/generated/models";
/** This just translates to...
type Todo = {
id: number;
title: string;
completed: boolean;
};
*/
type Todo = AllTodosResponseData["todos"][number];
// Typing our (destructured) props again. You've seen this before.
const ToDo = ({ todos }: AllTodosResponseData) => {
/* State variables */
const [todosList, setTodosList] = useState(todos);
// for client state management, keep a track of the highest ID seen so far
const [highestId, setHighestId] = useState(
todos?.reduce((maxId, todo) => Math.max(maxId, todo.id), 0)
);
const [task, setTask] = useState("Some task");
/* useSWRMutation Triggers */
const { error: errorOnCreate, trigger: createTrigger } = useMutation({
operationName: "CreateTodo",
});
const { error: errorOnDelete, trigger: deleteTrigger } = useMutation({
operationName: "DeleteTodo",
});
const { error: errorOnUpdate, trigger: updateTrigger } = useMutation({
operationName: "UpdateTodo",
});
/* Event handlers */
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// update client state
const newTodo: Todo = {
id: highestId + 1,
title: task as string,
completed: false,
};
setTodosList([...todosList, newTodo]);
// update server state
createTrigger({
task: task as string,
});
// stuff to do after
setTask(""); // reset task
setHighestId(highestId + 1); // set new highest id value
};
const handleDelete = (id: number) => {
// update client state
const updatedList = todosList.filter((todo) => todo.id !== id);
setTodosList(updatedList);
// update server state
deleteTrigger({
id: id,
});
};
const handleCheck = (changedTodo: Todo) => {
// update client state
const updatedList = todosList.map((todo) => {
if (todo.id === changedTodo.id) {
return {
...todo,
completed: !todo.completed,
};
}
return todo;
});
setTodosList(updatedList);
// update server state
updateTrigger({
id: changedTodo.id,
complete: !changedTodo.completed,
});
};
return (
<div className="md:w-10/12 lg:w-8/12 lg:mr-64 bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 text-slate-800 p-6 rounded-lg shadow-md">
<h1 className="text-xl font-bold mb-6">Typesafe Todos</h1>
<form onSubmit={handleSubmit} className="flex mb-4">
<input
className="border-2 border-gray-300 p-2 w-full"
type="text"
placeholder="Add ToDo"
value={task}
onChange={(e) => setTask(e.target.value)}
/>
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-5 rounded"
type="submit"
>
Add
</button>
</form>
<ul className="list-none list-inside mt-4">
{todosList?.map((todo) => (
<li className="mb-2 flex justify-between items-center " key={todo.id}>
<label className="inline-flex items-center">
<input
type="checkbox"
className="cursor-pointer"
checked={todo.completed}
onChange={() => handleCheck(todo)}
/>
<span
className={`cursor-pointer hover:underline p-1 ml-2 ${
todo.completed ? "line-through" : ""
}`}
>
{todo.title}
</span>
</label>
<button
className="ml-2 bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-2 rounded"
onClick={() => handleDelete(todo.id)}
>
Delete
</button>
</li>
))}
</ul>
</div>
);
};
export default ToDo;
Here’s what’s happening here:
Remember how I said you don’t have to define your own types for the data you’re working with, because WunderGraph generated it for you automatically? Well…you get to import and use one of those auto-generated types now!
AllTodosResponseData
is literally just this (hover or Ctrl + click to see its definition inmodels.ts
):
export interface AllTodosResponseData {
todos: {
id: number;
title: string;
completed: boolean;
}[];
}
So if you wanted the type of each todo
in this array (because we'll need that too, later), all you’d have to do is:
type Todo = AllTodosResponseData["todos"][number];
// or...
type Todo = AllTodosResponseData["todos"][0]; // any number
- Hello again, typesafe hooks! WunderGraph’s default implementation of the NextJS client uses a wrapper around Vercel’s SWR for these. Specifically,
useMutation
in WunderGraph uses SWR’s useSWRMutation hook.
This means we get access to deferred or remote mutations – which are not executed until **explicitly **called with each trigger
function (for Create, Update, and Delete) via event handlers (and those should be self-explanatory).
- For event handlers in TypeScript, you can just inline them and let TS type inference figure it out.
<button onClick={(event) => { /* type inference will type 'event' automatically */ }} />
If you can’t (because of code readability, or if performance is a concern), you’ll have to be explicit with the type for your custom event handlers.
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
// handle submit logic here
}
If you don’t, they’ll default to the ‘any’ type, implicitly, which adds ambiguity – and thus a big no-no. There are ways of getting around this by turning off/changing some safeguards in tsconfig.json, but I’d recommend against it when you’re just learning TypeScript.
Wondering which type to use, and where? That’s simple! Just hover over the onChange/onSubmit/onClick
etc. in your JSX, and the IDE will tell you.
In keeping with best practices, we do not directly manage server state by making database transactions for each mutation, but have a separate client state that we optimistically update the UI with (and that’s why we need to track the highest ID seen so far), and periodically sync it with server state.
To know more about server vs. client state and why you shouldn’t be managing the former, I suggest checking out this excellent blog post by Dominik Dorfmeister. Reading this was a revelation.
We’re done!
Visit localhost:3000 in a browser, and you should be able to add, delete, and check Todos as complete, and see the resulting server data live on the left.
In Summary…
It’s 2023, and type safety is no longer a thing that only library maintainers have to worry about. It’s time to stop being scared, and realize that it can actually make your life as a dev much, much easier.
And using TypeScript, WunderGraph, and Prisma together to build your apps could be the perfect “lightbulb moment” for this.
TypeScript ensures that the codebase is correctly typed, while Prisma ensures that the interactions with your data are also type-safe, and WunderGraph plays the perfect matchmaker by ensuring that the API interfaces that facilitate this 2-way data communication are also type-safe, with reduced boilerplate/glue code, optimized network calls (perks of using GraphQL at build time), and improved development speed and efficiency.
This is an exceptionally powerful stack that will get you through most of your projects. End-to-end typesafety, no additional dependencies, with fantastic devex to boot. You could literally change something on the Server/backend and immediately see the changes reflected in the Client. You won’t spend _more _time coding in TypeScript. On the contrary, you spend much less, now that a supercharged IDE can help you.