loading...
Cover image for Productive Development With Prisma’s Zero-Cost Type Safety
Prisma

Productive Development With Prisma’s Zero-Cost Type Safety

2color profile image Daniel Norman ・9 min read

Handling data is at the core of web applications and comes with many challenges as data travels through different components of the application's code.
In this article, we'll look at Prisma's zero-cost type safety and how it boosts productivity and increases developer confidence in applications that use a relational database.

The journey of data in web applications

If you've been building web applications, there's a good chance you've spent a lot of your time handling data. As a developer, your concerns with data start in the UI, as users enter information or interact in a way that creates information. This is where the long data journey begins. The journey usually ends in a database; from which it may go on many more journeys as it's fetched, updated, and saved again.

💡 The post assumes a basic understanding of relational databases and full-stack development.

In a typical three-tier application, the journey looks as follows:

  1. The data is sent via HTTP from the user's browser by the frontend to the backend server (or a serverless function) via an API, for example a GraphQL or REST API.
  2. The backend finds the matching route and its handler.
  3. The backend authenticates the user, deserializes the data, and validates the data.
  4. The route handler applies business logic to the data.
  5. The database access layer is used to safely store the data in the database.

Typical architecture

Each of the components that the data moves through may manipulate and transform the data. With JavaScript, there's a common problem when multiple components interact with data: type errors.

A type error is an error that occurs when a value in an operation is of a different type from what the code expects.

For example, a function that concatenates the first and last name of a user object may run into a type error:

function getFullName(user) {
  return `${user.firstName} ${user.lastName}`
}

Calling the function without passing in a parameter raises a type error:

getFullName() // Uncaught TypeError: Cannot read property 'firstName' of undefined

Calling the function with an object missing the properties will not raise an error:

getFullName({}) // "undefined undefined"

getFullName({ firstName: 'Shakuntala' }) // "Shakuntala undefined"

This is due to JavaScript's ability to convert types during runtime. In this case, undefined is converted to string. This feature is known as implicit type coercion.

With JavaScript, these errors occur at runtime. In practice, this means that these errors are discovered during testing or after the application has been deployed.

Type safety with TypeScript

In recent years, TypeScript became popular amongst JavaScript developers as a typed language that compiles to JavaScript. One of the main benefits that TypeScript offers is the ability to detect type errors at compile time which increases confidence in the applications you're building.

For example, we can define the getFullName function from above as follows:

function getFullName (user: {firstName: string, lastName: number}) => (
  return `${user.firstName} ${user.lastName}`
)

getFullName({}) // Type error

Since the call below the function definition is invalid, the error will be caught when the TypeScript compiler is run:

$ tsc example.ts

example.ts:5:13 - error TS2345: Argument of type '{}' is not assignable to parameter of type '{ firstName: string; lastName: number; }'.
  Type '{}' is missing the following properties from type '{ firstName: string; lastName: number; }': firstName, lastName

5 getFullName({})

Benefits of TypeScript aside, when comparing TypeScript to JavaScript, it comes at a cost of defining types which often reduces productivity.

Changing data and type errors

Type errors are especially common during rapid development and prototyping where introducing new features requires changes to the structure of the data.

For example, a blog may have the concept of Users and Posts, whereby, an author can have many posts. Typically, each of these two entities would have a structure like in the following diagram:

Database schema

If you decide to rename the name field to firstName and add a lastName field you will need to update the database schema. But once the database schema has been migrated (updated to have a new structure), the backend may fail as its queries still point to the name field which doesn't exist.

Altered database schema

This kind of change is called a schema migration, and there are many ways to deal with such changes. For example, the naive approach could look as follows:

You schedule a maintenance window and use the time before to:

  1. Update the backend code to use the new field.
  2. Migrate the database schema in a test environment.
  3. Test the updated backend with the migrated database schema.
  4. If the testing succeeds, use the maintenance window to take down the old version of the backend, migrate the database schema, and then deploy the updated backend.

One of the problems with this approach (besides having to take the service down) is that updating the code to use the new field is a manual process. Because code accessing the old name field is still syntactically valid, type errors will happen when the code runs. Specifically, no error will be thrown, as accessing undefined fields doesn't throw a TypeError like in the getFullName example above.

Adapting the code to the new schema can be done in a couple of ways, which can be combined:

  • Manually searching the code for all occurrences of name and adjusting them to work with the schema change.
  • With unit and integration tests. You can start the process by creating new tests to describe the expected behavior after the change. The tests fail initially and as the code is updated, they gradually pass as the code is adapted to make use of the new fields.

Depending on how you're accessing your database, either approach can be a cumbersome task. With an SQL query builder like knex.js, you have to search for queries using the old name field and update them. With ORMs, you typically have to update the User model and ensure the model isn't used to access or manipulate the old name field.

In an application using knex.js, the change looks as follows:

const user = await db('users')
-  .select('userId', 'name', 'twitter', 'email)
+  .select('userId', 'firstName', 'lastName', 'twitter', 'email)
  .where({
    userId: requestedUserId
  })

await db('users')
  .where({ userId: userIdToUpdate })
-  .update({ name: newName })
+  .update({ firstName: newFirstName, lastName: newLastName })

The challenge here, irrespective of the specific database abstraction is that you need to coordinate changes between the database and your codebase.

Prisma approach eases the coordination work between the codebase and the database schema.

Prisma – modern database toolkit

Prisma 2 is an open-source database toolkit that was built with the benefits of type safety in mind.

In this post, we'll look at Prisma Client, the toolkit's type-safe database client for Node.js and TypeScript.

💡 Prisma Migrate, the Prisma's migration tool is currently in an experimental state. For more information, check out the Prisma Migrate documentation

Prisma is database agnostic and supports different databases including PostgreSQL, MySQL, and SQLite.

The generated Prisma Client is in TypeScript, which makes type safety possible. **The good news is that you can reap some of the rewards of type safety in a Node.js application written in JavaScript without having to invest the time defining types for the database layer.

Moreover, Prisma can serve as a gateway to a deeper understanding of TypeScript's benefits.

Schema centric workflow

Prisma uses the Prisma schema as a declarative and typed schema for your database. It serves as the source of truth for both the database and the client, which is auto-generated from the Prisma schema. The Prisma schema is just another representation of your database. For the example above, the corresponding Prisma schema would look as follows:

model User {
  id      Int     @default(autoincrement()) @id
  email   String  @unique
  name    String?
  twitter String?
  posts   Post[]
}

model Post {
  postId   Int     @default(autoincrement()) @id
  title    String
  content  String?
  author   User?   @relation(fields: [authorId], references: [id])
  authorId Int?
}

Prisma supports different workflows depending on whether you are starting from scratch or with an existing database.

Assuming you have a database schema already defined (with SQL or with a migration tool), Prisma's workflow looks as follows from a high level:

  1. You introspect the database using the Prisma CLI which creates the Prisma schema.
  2. You use the CLI to generate the Prisma Client (which uses the Prisma schema as a representation of the database schema). You get a node module that is tailored to your database schema.

Introspection workflow

With the database introspected and the Prisma Client generated, you can now use Prisma Client as follows:

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

// An example route handler for POST /api/user
// Required fields in body: name, email
export default async function handle(req, res) {
  const { name, email } = req.body
  const createdUser = await prisma.user.create({
    data: {
      name,
      email,
    },
  })

  res.json({
    id: createdUser.id,
    name: createdUser.name,
    email: createdUser.email,
  })
}

The appeal of generated Prisma Client (as imported from @prisma/client) is that all input parameters and return values of the prisma methods are fully typed. For example, in VSCode you can right-click on createdUser and Go to Type Definition which will lead to the generated TypeScript code:

export type User = {
  id: number
  email: string
  name: string | null
  twitter: string | null
}

Because of those types, it's possible for tooling, e.g. code editors and CLI tools to do a lot of checks behind the scenes and provide you with actionable feedback as you're writing code. For example, trying to access a non-existent field, e.g. createdUser.address would be quickly detectable and could be notified.

With a better understanding of the relationship between the database schema, the Prisma schema, and the generated Prisma Client, let's look at the tools that provide such actionable feedback with JavaScript by using the generated types behind the scenes.


Productive and safe development with zero-cost type safety

The benefits of type safety can be had at zero cost in a project using JavaScript with Prisma. This means that you become more confident in your code without any additional effort.

There are several levels to it.

Level 1: Autocomplete suggestions

The first example of zero-cost type safety is the way that VSCode IntelliSense suggestions pop up as you type:

autocomplete in action

Autocomplete in action

The generated @prisma/client is a CRUD API that is tailored to your database schema and is fully typed in TypeScript. This allows VSCode's IntelliSense to give typed autocomplete suggestions during development.


Level 2: Type safety validations in VSCode

Suggestions are a nice feature that improves productivity and reduces juggling between reading documentation and coding. You can get errors –the same way linters work in VSCode– when your code uses the Prisma API in unintended ways, thereby violating types.

Add // @ts-check to the top of JavaScript files which use the Prisma Client. VSCode will run your code through the TypeScript compiler and report back errors:

With @ts-check the input parameters `prisma.user.create()` and the return value `createdUser` are typed and match the DB field types

With @ts-check the input parameters `prisma.user.create()` and the return value `createdUser` are typed and match the DB field types

If you narrow the returned fields with select in the call to prisma.user.create() the returned createdUser will be typed accordingly:

Selection set and type errors when accessing unselected fields

Selection set and type errors when accessing unselected fields

For this to work enable syntax checking in VSCode:

Set javascript.validate.enable to true in your VSCode configuration:

{
  "javascript.validate.enable": true
}

While this provides valuable feedback during development, nothing stops you from committing or deploying code with errors. This is where automated type checks can be useful.


Level 3: Automated type checks in CI

In a similar fashion to how VSCode runs the TypeScript compiler for type checks, you can run the type checks in your CI or as a commit hook.

  1. Add the TypeScript compiler as a development dependency:
npm install typescript --save-dev
  1. Run the TypeScript compiler:
npx tsc --noEmit --allowJs --checkJs pages/api/*.js

To run as a commit hook:

Husky allows you to define commit hooks in your package.json

You can install Husky:

npm install husky --save-dev

And add the hook:

{
  // package.json
  "husky": {
    "hooks": {
      "pre-commit": "tsc --noEmit --allowJs --checkJs pages/api/*.js"
    }
  }
}

Conclusion

Type errors are a common problem in JavaScript and because they are noticed at runtime, detecting can be difficult without rigorous testing. When working with data that travels through many components and a database, the risk associated with such type errors increases.

TypeScript's type safety alleviate some of those risks but come at a cost of learning TypeScript and defining types upfront.

In applications that rapidly change to accommodate for new features, the database schema must be adapted with schema migrations and in turn the application code.

Having to manually manage such migrations can be error-prone and cumbersome, which reduces the ability to iterate on an application quickly without introducing errors.

Prisma addresses these challenges with a schema centric workflow and an auto-generated TypeScript database client. These features make for a pleasant developer experience as they boost productivity and increase confidence, with autocompletion and automated type checks during build time.

These benefits come at zero cost because as a developer you are not required to take any extra precautions or steps to benefit from type safety using Prisma. Most importantly, all of this is available in projects written exclusively in JavaScript.

Posted on by:

2color profile

Daniel Norman

@2color

Developer advocate at @Prisma. Interested in JavaScript, Databases, Ethereum, Kubernetes, Golang, GraphQL, and Prisma.

Prisma

Prisma is an open-source database toolkit. It replaces traditional ORMs and makes database access easy with an auto-generated query builder for TypeScript & Node.js.

Discussion

markdown guide
 
 

We're using prisma2 from the preview stage, and it was stable even in beta.

Excited about the stable release

 

Thanks for sharing Saurabh. Glad to hear Prisma 2 is working well for you.

 

Really great post!

I just started a project using TypeORM and decided now to move to Prisma.

 

Looking forward to hearing more about your experience moving from TypeORM. What do you plan on using for migrations?

 

I was considering Prisma Migration. Do you think is not mature enough?

It's still in the experimental state, but I highly recommend checking it out already and getting a feel for it.

Sure! Will do it. Thanks for the advice.

 

Loved the blog post. Congrats on getting out of beta 👏