Author in TypeScript
In Prisma ORM 8, schema.prisma is replaced by a file called the contract, which holds the model definitions you used to write in schema.prisma. You can write it in two forms: one is a .prisma file written in PSL, short for Prisma Schema Language, and the other is TypeScript, in src/prisma/contract.ts, built with the defineContract builder. Both forms produce the same two files: npx prisma contract emit writes contract.json and contract.d.ts into the folder that holds your contract file.
PSL is the preferred way to write the contract, and both forms produce the same two files, so you give up nothing by staying with PSL. Use the TypeScript builder for the cases PSL does not cover, and reach for it when:
- model definitions must be split, composed, or reused across ordinary TypeScript modules or packages
- you want to build models in a loop from data you already keep in TypeScript, such as one model per entry in a list of table names
If neither applies, write PSL, which is more compact and is what contract infer writes.
For a new project, run npx prisma orm init, which asks how you want to write your schema, and choosing TypeScript creates the contract file and the config together.
The config's contract path names the one file Prisma ORM reads, and a .ts extension selects TypeScript authoring. An optional output names a directory for contract.json and contract.d.ts, which otherwise land next to the contract file; npm create prisma@latest sets it to ./src/prisma/generated for TypeScript projects:
import { definePrismaConfig } from "prisma/config";
import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";
export default definePrismaConfig({
orm: ormConfig({
contract: "./src/prisma/contract.ts",
output: "./src/prisma/generated",
}),
});import { definePrismaConfig } from "prisma/config";
import { defineConfig as ormConfig } from "@prisma/orm-mongo/config";
export default definePrismaConfig({
orm: ormConfig({
contract: "./src/prisma/contract.ts",
}),
});The builder comes from your database's package: @prisma/orm-postgres/contract-builder for PostgreSQL, @prisma/orm-mongo/contract-builder for MongoDB.
Export the contract under the name contract, as below, or as the file's default export, because those are the only two names prisma contract emit looks for. Run npx prisma contract emit again after every edit to the contract, and commit the contract file together with contract.json and contract.d.ts.
import { defineContract, enumType, member } from "@prisma/orm-postgres/contract-builder";
// nativeType is the column type the database gets. codecId picks how Prisma ORM converts the value between TypeScript and that column, and its @1 is the version of that conversion.
const pgText = { codecId: "pg/text@1", nativeType: "text" } as const;
const Priority = enumType("Priority", pgText, member("Low", "low"), member("High", "high"));
export const contract = defineContract({}, ({ field, model, rel }) => {
const User = model("User", {
fields: {
id: field.id.uuidv4String(),
email: field.text(),
createdAt: field.temporal.createdAt(),
address: field.json().optional(),
},
});
const Post = model("Post", {
fields: {
id: field.id.uuidv4String(),
title: field.text(),
userId: field.uuidString(),
priority: field.namedType(Priority).default(Priority.members.Low),
createdAt: field.temporal.createdAt(),
updatedAt: field.temporal.updatedAt(),
},
});
return {
enums: { Priority },
models: {
User: User.relations({ posts: rel.hasMany(Post, { by: "userId" }) }).sql({ table: "user" }),
Post: Post.relations({
user: rel.belongsTo(User, { from: "userId", to: "id" }).sql({ fk: { name: "post_userId_fkey" } }),
}).sql({ table: "post" }),
},
};
});import { defineContract, field, model, rel } from "@prisma/orm-mongo/contract-builder";
const User = model("User", {
collection: "users",
fields: {
_id: field.objectId(),
email: field.string(),
},
relations: {
posts: rel.hasMany("Post", { from: "_id", to: "authorId" }),
},
});
const Post = model("Post", {
collection: "posts",
fields: {
_id: field.objectId(),
authorId: field.objectId(),
title: field.string(),
publishedAt: field.date().optional(),
},
relations: {
author: rel.belongsTo(User, { from: "authorId", to: User.ref("_id") }),
},
});
export const contract = defineContract({ models: { User, Post } });On PostgreSQL, defineContract takes an options object and then a function. You do not set a provider anywhere: importing @prisma/orm-postgres/contract-builder is what selects PostgreSQL. The options object lists the extension packs you use, and you write {} when you use none. The function returns the contract's content: models, plus enums, types, and entities if you have them.
The options object takes four more keys, all optional:
| Option | What it does |
|---|---|
naming: { tables: "snake_case", columns: "snake_case" } |
Derives table and column names from model and field names, so createdAt becomes created_at without a .column(...) call on every field. Set either key or both. |
foreignKeyDefaults: { constraint: true, index: true } |
Gives every rel.belongsTo a foreign key constraint and an index in the database. Without this option a relation gets neither unless its own .sql({ fk }) asks; see Relations. |
defaultControlPolicy: "managed" |
The control policy for every model that does not set its own. |
namespaces: ["audit"] |
The PostgreSQL schemas other than public that models may use; see Namespaces. |
Take field, model, rel, and type from the function's one argument, and import defineContract, enumType, and member from the package. The package also exports model and rel for use outside the function, plus a field that has only column, generated, and namedType.
On MongoDB, defineContract also accepts a single object holding models, as above, and you import field, model, and rel from the package instead. The MongoDB builder differs from the PostgreSQL one in five ways:
- Every model declares an
_idfield built withfield.objectId(). That field is always the primary key, so you never mark it with.id()and there is no.attributes(...)on MongoDB. - The scalar helpers are
field.objectId(),field.string(),field.int32(),field.double(),field.bool(), andfield.date(). - The collection name is an inline option on the model rather than a chained call, and relations are inline as well.
- Relations name the field on each side.
rel.hasManytakes{ from, to }on MongoDB and{ by }on PostgreSQL.toaccepts either the field name as a string or a typed reference such asUser.ref("_id"), and both forms mean the same thing. PostgreSQL writes that reference asUser.refs.id. - Indexes are an
indexesoption on the model, built with theindexhelper the same package exports, as inindexes: [index({ email: 1 }, { unique: true })]. The1is ascending, MongoDB's own index syntax.
Enums and extension packs work on MongoDB too: enumType and member come from @prisma/orm-mongo/contract-builder. Pass the enums in the same object as the models, as defineContract({ models, enums }), and extension packs go in the same extensions option.
The MongoDB builder also has, on the model object:
indexes, a list ofindex(keys, options?)calls.keysmaps each field to1,-1,"text","2dsphere","2d", or"hashed", andoptionstakes MongoDB's own index options:unique,sparse,name,expireAfterSeconds,partialFilterExpression,collation,weights, andwildcardProjection. For example,index({ expiresAt: 1 }, { expireAfterSeconds: 3600, sparse: true }).collectionOptions, for options on the collection itself, such as{ collation: { locale: "en", strength: 2 } }.discriminatorandbase, for one collection that holds more than one kind of document. The base model declaresdiscriminator: { field: "kind", variants: { Article: { value: "article" } } }, and each variant model declaresbase: Postand the samecollectionas its base, plus its own fields. MongoDB data modeling covers when to use it.
And two more field helpers: field.vector() for a vector, which takes no dimension count, and field.valueObject(Address) for an embedded document, where Address is declared with valueObject("Address", { fields: { ... } }) from the package and returned in defineContract's valueObjects map:
import { defineContract, field, index, model, valueObject } from "@prisma/orm-mongo/contract-builder";
const Address = valueObject("Address", {
fields: { street: field.string(), zip: field.string().optional() },
});
const User = model("User", {
collection: "users",
fields: {
_id: field.objectId(),
email: field.string(),
address: field.valueObject(Address).optional(),
embedding: field.vector().optional(),
},
indexes: [index({ email: 1 }, { unique: true, collation: { locale: "en", strength: 2 } })],
});
export const contract = defineContract({ models: { User }, valueObjects: { Address } });The examples below use the PostgreSQL builder.
field has a helper for each column type. On PostgreSQL:
| Column | Helper |
|---|---|
| text | field.text() |
| integer | field.int() |
| big integer | field.bigint() |
| float | field.float() |
| decimal | field.decimal() |
| boolean | field.boolean() |
| date and time | field.dateTime() |
| bytes | field.bytes() |
| JSON | field.json() |
UUID stored in a character(36) column |
field.uuidString() |
UUID stored in a uuid column |
field.uuidNative() |
There is no helper for a date without a time, or a time without a date. For a date-only column, name the type yourself: field.column({ codecId: "pg/date-temporal@1", nativeType: "date" } as const). field.column(...) and the chained .column(...) below are different calls: field.column(descriptor) builds a field from a type description, and .column(name) on an existing field sets its column name. Extension packs add more helpers of their own.
For a primary key your app generates, use a field.id.* helper. Each one marks the field as the primary key and generates the ID in your app when you create a row. field.id.uuidv4String() is the common choice, and the others are field.id.uuidv7String(), field.id.ulid(), field.id.nanoid(), field.id.cuid2(), and field.id.ksuid(). To store a UUID in a uuid column instead of a character(36) column, use field.id.uuidv4Native() or field.id.uuidv7Native().
For a key the database generates, chain .default(...) and .id() on a field. An integer that counts up is field.int().default(autoincrement()).id(), and a UUID is field.uuidNative().default(sql`gen_random_uuid()`).id(). Import autoincrement and sql from @prisma/orm-postgres/contract-builder.
field.temporal.createdAt() sets the column to the current time when the row is created, and field.temporal.updatedAt() sets it on every create and every update. Prisma ORM sets both in the client, from your application's clock, so the column has no database default. If you insert a row with raw SQL, you must supply the value yourself. For a time the database sets instead, use field.dateTime().default(now()), with now imported from @prisma/orm-postgres/contract-builder.
field.dateTime(), field.temporal.createdAt(), and field.temporal.updatedAt() give your code a Temporal.Instant, so your app needs a global Temporal to read and write them; Scalar fields says how to check for it and which polyfill to load when it is missing. To get a JavaScript Date instead, and need no Temporal, use field.temporal.timestamptzJsDate() for a timestamptz column, and field.temporal.createdAtJsDate() and field.temporal.updatedAtJsDate() for creation and update times.
field.namedType(x) takes an enum or a type from an extension pack.
Every field builder supports chained modifiers:
.optional()makes the field nullable..default(value)sets a fixed default. A decimal default is a string, as in.default("1.50"), and a JSON default is the object or array itself, as in.default({ plan: "free" }). The value has the type your code writes to that field, because the field's codec encodes it: afield.dateTime()default is aTemporal.Instant, as in.default(Temporal.Instant.from("2024-01-01T00:00:00Z")), and afield.bigint()default is abigint, as in.default(1n). To write the time as a string, usefield.temporal.timestamptzString().default("2024-01-01T00:00:00Z"). A value of the wrong type is a type error, and a value the codec refuses makescontract emitfail withCONTRACT.DEFAULT_INVALID. Do not write.default(null): an.optional()field without a default is alreadyNULLin the database when you leave it out..default(...)also takes a default the database computes:.default(now()),.default(autoincrement()), or any other SQL expression as asqltemplate, such as.default(sql`gen_random_uuid()`). Importnow,autoincrement, andsqlfrom@prisma/orm-postgres/contract-builder.sql`now()`andsql`autoincrement()`are refused, so use the named helpers..defaultSql(expression)still works but is deprecated, and it will be removed in the stable8.0.0release..unique()adds a unique constraint..id()marks the primary key, for a key that is not generated, such as an integer you set yourself..column("column_name")sets the column name in the database when it differs from the field name..many()makes the field a list, stored as a PostgreSQL array column such astext[]. A list column gets a check constraint that rejectsNULLelements..noCheck()leaves out the check constraints Prisma ORM generates for the column: the one that keeps an enum column to the enum's values, and the one that keepsNULLout of a list..noCheck("membership")and.noCheck("elementNotNull")leave out one or the other. The field's TypeScript type does not change.
enumType declares an enum, the type its values are stored as, and its members, as Priority does in the full example above.
The second argument says how each member is stored, as the same codecId and nativeType pair the full example above explains. Write as const after the object, so TypeScript keeps the exact strings. To store the members as integers, pass { codecId: "pg/int4@1", nativeType: "int4" } as const. More type ids are listed with the raw query param helper, which names types the same way.
Each member(name, storedValue) pairs the TypeScript-visible name with the value stored in the column. Fields reference the enum with field.namedType(Priority), and defaults reference a member as Priority.members.Low. Include the enum in the returned enums map so it reaches the two files.
Members are stored in whatever column type you name here, text above. For a real PostgreSQL enum type, declare it with nativeEnum and type the field with pg.enum, both exported by @prisma/orm-postgres/contract-builder. The type is created in the database under the name you give it:
import { defineContract, nativeEnum, pg } from "@prisma/orm-postgres/contract-builder";
const Role = nativeEnum("Role", "user", "admin");
export const contract = defineContract({}, ({ field, model }) => {
const Account = model("Account", { fields: { role: field.column(pg.enum(Role)) } });
return { models: { Account: Account.sql({ table: "account" }) } };
});Role does not go in the returned enums map: using it on a field is enough to create the type in the database.
Relations are declared on the model builder with .relations(...) and the rel helpers, as the full example above shows.
rel.hasMany(Model, { by }) names the foreign key field on the other model, and rel.hasOne(Model, { by }) is the same with at most one row on the other side. rel.belongsTo(Model, { from, to }) maps the local foreign key field to the field it points at.
rel.manyToMany(Model, { through, from, to }) goes through a join table: you declare the model for the join table yourself and pass it as through. Below, PostTag is that model, holding the two foreign key fields postId and tagId:
Post.relations({
tags: rel.manyToMany(Tag, { through: PostTag, from: "postId", to: "tagId" }),
});rel.belongsTo on its own does not create a foreign key constraint in the database, so ask for one by chaining .sql(...) on the relation itself, inside the .relations({ ... }) object, as the full example above does:
Post.relations({
user: rel.belongsTo(User, { from: "userId", to: "id" }).sql({ fk: { name: "post_userId_fkey" } }),
});fk takes name, onDelete, and onUpdate. onDelete and onUpdate each take 'noAction', 'restrict', 'cascade', 'setNull', or 'setDefault'.
Prisma ORM does not check that the column types on the two sides of a relation match, so choose a field helper that produces the same column type as the key you point at.
rel.hasMany, rel.hasOne, rel.belongsTo, and rel.manyToMany also accept the model name as a string. Pass the model object, as above, and a typo is a compile error, but pass a string and the typo is reported when prisma contract emit builds the contract.
.sql(...) maps a model to its table, and the object form covers the common case: User.sql({ table: "user" }). Without a table, and without the naming option, the table has the model's name exactly as written, so User is the table "User".
You can chain .relations(...), .attributes(...), and .sql(...) in any order, and you can skip any of them, so a model with no relations calls .sql({ table }) straight after model(...).
The callback form gives you the model's columns as cols and the constraint builders as constraints. Use it for indexes:
Post.relations({ ... }).sql(({ cols, constraints }) => ({
table: "post",
indexes: [
constraints.index([cols.userId]),
constraints.index([cols.userId, cols.createdAt], { name: "post_user_created_idx" }),
],
}));constraints.index takes a list of columns, even when the list has one entry, or an object with expression, the whole index expression as SQL. The options are unique, where (a partial-index condition as SQL, without the WHERE keyword), type with options (the index method and its parameters; options: {} when there are none), and name or map. name: "user_handle_active" creates an index called user_handle_active_2a0c4277, with a hash on the end; map sets the exact name, which is what you want when the index already exists. An expression index needs one of the two:
indexes: [
constraints.index([cols.handle], { where: "(handle IS NOT NULL)", name: "user_handle_active" }),
constraints.index({ expression: "lower(handle)", unique: true, name: "user_handle_lower" }),
constraints.index([cols.tags], { type: "gin", options: {}, name: "user_tags_gin" }),
],The where and expression strings go into the SQL as written, so they use column names, and you quote them yourself.
The primary key, unique constraints, indexes, and foreign keys of one model must all have different names. A name used twice is a type error that your editor and tsc report, but npx prisma contract emit does not type-check the file, and it accepts the repeated name because each name gets a different hash on the end in the database. A map used twice makes contract emit fail with CONTRACT.SOURCE_LOAD_FAILED.
For PostgreSQL full-text search, fullTextIndex from @prisma/orm-postgres/contract-builder creates the GIN index that a text field's fullTextMatches and fullTextRank query functions use. It takes one text column and a name or map, plus optional language and where:
import { fullTextIndex } from "@prisma/orm-postgres/contract-builder";
Post.sql(({ cols }) => ({
table: "post",
indexes: [fullTextIndex(cols.title, { name: "post_title_search" })],
}));language defaults to english on both the index and the query functions, and PostgreSQL uses the index only when the two match, so change both or neither. To search German text, for example, write fullTextIndex(cols.title, { name: "post_title_search", language: "german" }) on the index and p.title.fullTextMatches(query, { language: "german" }) in the query.
Two more keys go in the same object, in either the object form or the callback form: checks, a list of check constraints you write yourself, built with check from the package, and control, the model's control policy:
import { check } from "@prisma/orm-postgres/contract-builder";
Order.sql({
table: "order",
checks: [check({ expression: "total >= 0", name: "order_total_positive" })],
});expression is the condition as SQL, using column names, and name and map work as they do on an index.
Model.refs provides typed references to another model's fields, for the constraint builders inside .sql(...). Write constraints.foreignKey(cols.userId, User.refs.id) and TypeScript checks id against the actual User definition.
For a primary key made of two fields, use .attributes(...) instead of .sql(...), and build it from the field references:
PostTag.attributes(({ fields, constraints }) => ({
id: constraints.id([fields.postId, fields.tagId]),
}));That object accepts exactly two keys, id and uniques. id takes one constraint, and uniques takes a list, so uniques: [constraints.unique([fields.postId, fields.tagId])] makes a unique constraint across two fields. For a key made of one field, .id() on the field is enough, and the field.id.* helpers already do it.
A model can go in a PostgreSQL schema other than public: declare the schema in defineContract's namespaces option, then name it on the model. Without the declaration, npx prisma contract emit fails and names the missing entry.
export const contract = defineContract({ namespaces: ["audit"] }, ({ field, model }) => {
const AuditLog = model("AuditLog", {
namespace: "audit",
fields: { id: field.id.uuidv4String(), message: field.text() },
});
return { models: { AuditLog: AuditLog.sql({ table: "audit_log" }) } };
});control on .sql(...) says how far Prisma ORM manages the table, with one of four values:
| Value | db verify |
Migrations |
|---|---|---|
"managed" (the default) |
The table must exist and match the model exactly. | Create, alter, and drop it. |
"tolerated" |
Declared columns must match; extra columns are accepted. | Create it if missing; never alter or drop it. |
"external" |
Declared columns must match; extra columns and constraints are ignored. | Never touch it. |
"observed" |
Anything goes; a mismatch is a warning, not a failure. | Never touch it. |
Put it on a model whose table something else owns:
AuditLog.sql({ table: "audit_log", control: "observed" })defaultControlPolicy in defineContract's options sets the policy for every model that does not set its own.
The package exports the pieces of PostgreSQL row-level security, and migration plan turns them into ENABLE ROW LEVEL SECURITY and CREATE POLICY statements. rlsEnabled(Model) turns row-level security on for the model's table. policySelect, policyInsert, policyUpdate, policyDelete, and policyAll each declare one policy for one operation, and role("name") declares a database role a policy names. Return them all in the entities list:
import { defineContract, policySelect, policyUpdate, rlsEnabled, role } from "@prisma/orm-postgres/contract-builder";
export const contract = defineContract({}, ({ field, model }) => {
const User = model("User", { fields: { id: field.id.uuidv4String() } });
const authenticated = role("authenticated");
return {
models: { User: User.sql({ table: "user" }) },
entities: [
authenticated,
rlsEnabled(User),
policySelect(User, { name: "user_self_read", roles: [authenticated], using: "id = current_setting('app.user_id')::uuid" }),
policyUpdate(User, { name: "user_self_write", roles: [authenticated], using: "id = current_setting('app.user_id')::uuid", withCheck: "id = current_setting('app.user_id')::uuid" }),
],
};
});roles lists role handles, and a role Prisma ORM should create goes in entities as well, as authenticated does above, while a role that already exists in the database, such as public, is role("public") in roles and left out of entities. using and withCheck are the two conditions as SQL, using column names: policySelect and policyDelete take using, policyInsert takes withCheck, and policyUpdate and policyAll take either or both. The policy name gets an eight-character hash on the end in the database.
An extension pack is an npm package that adds column types to the builder. List packs in defineContract's options object, and the type helper exposes their constructors:
import pgvector from "@prisma/orm-extension-pgvector/pack";
import { defineContract } from "@prisma/orm-postgres/contract-builder";
export const contract = defineContract({ extensions: { pgvector } }, ({ field, model, type }) => {
const types = { Embedding1536: type.pgvector.Vector(1536) } as const;
const Post = model("Post", {
fields: { id: field.id.uuidv4String(), embedding: field.namedType(types.Embedding1536) },
});
return { types, models: { Post: Post.sql({ table: "post" }) } };
});The key on type.pgvector is the pack's own name, not the name you gave the import. The name is in the pack's documentation, and pgvector's is pgvector. Embedding1536 is a name you choose, and it appears under that name in contract.d.ts. Return the types map so the name reaches the two files.
Add the same pack to prisma.config.ts: it goes inside ormConfig({ ... }), beside contract, and the config imports the pack's /control export while the contract file imports its /pack export:
import { definePrismaConfig } from "prisma/config";
import pgvector from "@prisma/orm-extension-pgvector/control";
import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";
export default definePrismaConfig({
orm: ormConfig({
contract: "./src/prisma/contract.ts",
extensions: [pgvector],
}),
});Keep the contract file free of changing values
Section titled “Keep the contract file free of changing values”The contract file describes structure, and these rules keep it usable:
- Do not read
process.env, the current time, or random values into the contract. A contract built from those values makesnpx prisma contract emitproduce a differentcontract.jsonon each run. - Keep field values plain: strings, numbers, booleans, and the builder's own objects. Functions, class instances, and
Dateobjects do not serialize. - Keep the file free of side effects.
npx prisma contract emitloads the file with Node.js, so anything it does while loading, such as writing a file or calling a service, happens every time you run the command. The file is ordinary TypeScript, soimportmodel definitions from other files as usual. npx prisma contract emitruns the file but does not type-check it. Type errors show in your editor and when you runtsc.
Configuration that legitimately varies per environment, such as the database URL, belongs in prisma.config.ts, not in the contract.
TypeScript and PSL authoring produce the same contract.json and contract.d.ts for an equivalent contract, so you can move between the two forms without changing anything downstream. A project names exactly one contract file in its config.
No command converts a .prisma contract into TypeScript, so to move an existing project, write the TypeScript file by hand, point contract in prisma.config.ts at it, and delete the .prisma file so the two can never disagree.
Projects created with npm create prisma@latest include the Prisma ORM skills for your coding agent. In an existing project, run npx prisma skills sync to add them. The prisma-8 skill covers TypeScript authoring, so ask your agent to:
- "Convert this contract.prisma to the TypeScript schema builder."
- "Using the prisma-8 skill, add a unique constraint to the email field in our TypeScript schema."
- Inspect
contract.jsonandcontract.d.ts, the two files the contract produces. - Apply the contract to a database with
db initor plan changes withmigration plan.