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:
1) Remember those typesafe hooks we talked about? useQuery
is one such WunderGraph-generated typesafe data fetching hook weâll be using.
Essentially, you call useQuery
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 in wundergaph.operations.ts.
queries: (config) => ({
âŠ
liveQuery: {
enable: true,
pollingIntervalSeconds: 3,
},
}),
2) In data
, we get back the output of said query â an array of todos
. 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!
3) 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:
1) 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 in `models.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
2) 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).
3) 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.
4) 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.](https://tkdodo.eu/blog/react-query-and-forms#server-state-vs-client-state) Reading this was a revelation.
**Weâre done! **Visit http://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.
Top comments (0)