Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Generating TypeScript Interfaces

While building your own custom functionality into Payload, like Plugins, Hooks, Access Control functions, Custom Views, GraphQL queries / mutations, or anything else, you may benefit from generating your own TypeScript types dynamically from your Payload Config itself.

Types generation script

Run the following command in a Payload project to generate types based on your Payload Config:

1
payload generate:types

You can run this command whenever you need to regenerate your types, and then you can use these types in your Payload code directly.

Disable declare statement

By default, generate:types will add a declare statement to your types file, which automatically enables type inference within Payload.

If you are using your payload-types.ts file in other repos, though, it might be better to disable this declare statement, so that you don't get any TS errors in projects that use your Payload types, but do not have Payload installed.

1
// payload.config.ts
2
{
3
// ...
4
typescript: {
5
declare: false, // defaults to true if not set
6
},
7
}

If you do disable the declare pattern, you'll need to manually add a declare statement to your code in order for Payload types to be recognized. Here's an example showing how to declare your types in your payload.config.ts file:

1
import { Config } from './payload-types'
2
3
declare module 'payload' {
4
export interface GeneratedTypes extends Config {}
5
}

Custom output file path

You can specify where you want your types to be generated by adding a property to your Payload Config:

1
// payload.config.ts
2
{
3
// ...
4
typescript: {
5
// defaults to: path.resolve(__dirname, './payload-types.ts')
6
outputFile: path.resolve(__dirname, './generated-types.ts'),
7
},
8
}

The above example places your types next to your Payload Config itself as the file generated-types.ts.

Custom generated types

Payload generates your types based on a JSON schema. You can extend that JSON schema, and thus the generated types, by passing a function to typescript.schema:

1
// payload.config.ts
2
{
3
// ...
4
typescript: {
5
schema: [
6
({ jsonSchema }) => {
7
// Modify the JSON schema here
8
jsonSchema.$defs.Test = {
9
type: 'object',
10
properties: {
11
title: { type: 'string' },
12
content: { type: 'string' },
13
},
14
required: ['title', 'content'],
15
}
16
return jsonSchema
17
},
18
]
19
}
20
}
21
22
// This will generate the following type in your payload-types.ts:
23
24
export interface Test {
25
title: string
26
content: string
27
[k: string]: unknown
28
}

This function takes the existing JSON schema as an argument and returns the modified JSON schema. It can be useful for plugins that wish to generate their own types.

External schema references

You can use $ref to reference external JSON schema files in your custom schemas:

1
// payload.config.ts
2
{
3
typescript: {
4
schema: [
5
({ jsonSchema }) => {
6
jsonSchema.$defs.MyType = {
7
$ref: './schemas/my-type.json',
8
}
9
return jsonSchema
10
},
11
]
12
}
13
}

External references are resolved relative to your project's working directory (process.cwd()).

Example Usage

For example, let's look at the following simple Payload Config:

1
import type { Config } from 'payload'
2
3
const config: Config = {
4
serverURL: process.env.NEXT_PUBLIC_SERVER_URL,
5
admin: {
6
user: 'users',
7
},
8
collections: [
9
{
10
slug: 'users',
11
fields: [
12
{
13
name: 'name',
14
type: 'text',
15
required: true,
16
},
17
],
18
},
19
{
20
slug: 'posts',
21
admin: {
22
useAsTitle: 'title',
23
},
24
fields: [
25
{
26
name: 'title',
27
type: 'text',
28
},
29
{
30
name: 'author',
31
type: 'relationship',
32
relationTo: 'users',
33
},
34
],
35
},
36
],
37
}

By generating types, we'll end up with a file containing the following two TypeScript interfaces:

1
export interface User {
2
id: string
3
name: string
4
email?: string
5
resetPasswordToken?: string
6
resetPasswordExpiration?: string
7
loginAttempts?: number
8
lockUntil?: string
9
}
10
11
export interface Post {
12
id: string
13
title?: string
14
author?: string | User
15
}

Input and output types

Payload can optionally generate a second, write-shaped input type alongside the output (read) type for each collection and global. It's opt-in — enable it with typescript.generateInputTypes: true:

1
// payload.config.ts
2
{
3
// ...
4
typescript: {
5
generateInputTypes: true, // defaults to false
6
},
7
}

When enabled, you get two interfaces per entity:

  • Post — the output shape, returned by find, findByID, and similar read operations.
  • PostInput — the input shape, describing the data you write in create and update.

They diverge because reading and writing are not symmetric:

  • Relationship and upload fields are written as an ID, but on read they can be the fully populated document (depending on depth). The input type narrows them to the ID only.
  • id is optional on input — you may supply a custom ID or let Payload generate one.
  • createdAt / updatedAt, and the drafts _status field, are managed by Payload and omitted from the input type.
  • Fields with a defaultValue are optional on input, since Payload fills them in when omitted.
  • Virtual fields and join fields are read-only and omitted from the input type.

For the posts collection above, the input type narrows the relationship to an ID and drops the auto-managed fields:

1
export interface PostInput {
2
id?: string // optional — custom ID or Payload-generated
3
title?: string | null
4
author?: string | null // ID only, never the populated document
5
// no createdAt / updatedAt
6
}

Rich text fields follow the same rule: on input, relationship and upload nodes carry the ID only.

The input types are also exposed on the generated Config interface, alongside the existing maps:

1
import type { Config } from './payload-types'
2
3
type PostInput = Config['collectionsInput']['posts']
4
type MenuInput = Config['globalsInput']['menu']

Enabling input types is purely additive — the output types are byte-for-byte identical whether or not it's on, so turning it on only adds the new *Input defs and collectionsInput / globalsInput maps.

Using input types with the Local API

The Local API's create and update type their data against the output (read) shape, not the input shape. This is intentional: it keeps read-modify-write ergonomic — you can read a document at depth > 0 and write part of it back without the populated relationships failing to type-check.

The input types remain a valid subset of what those operations accept, so a value typed as PostInput is always assignable to create / update data. Reach for PostInput (or Config['collectionsInput'][...]) when you want to strictly type a write helper, a form payload, or a seed script.

Custom Field Interfaces

array, group and named tab fields generate a top-level interface only when you set interfaceName on them. block fields always generate a top-level interface — interfaceName on a block is an override of the auto-derived name (which is a PascalCase form of the slug: 'content-block' → ContentBlock).

The following group field config:

1
{
2
type: 'group',
3
name: 'meta',
4
interfaceName: 'SharedMeta', <-- here!!
5
fields: [
6
{
7
name: 'title',
8
type: 'text',
9
},
10
{
11
name: 'description',
12
type: 'text',
13
},
14
],
15
}

will generate:

1
// a top level reusable interface!!
2
export interface SharedMeta {
3
title?: string
4
description?: string
5
}
6
7
// example usage inside collection interface
8
export interface Collection1 {
9
// ...other fields
10
meta?: SharedMeta
11
}

Block interface name collisions

Every block generates a top-level interface named after its slug ('hero' → Hero). If two different blocks resolve to the same name but have different fields — for example a hero block reused across collections but overridden in one of them — a single shared Hero interface couldn't represent both shapes. The first block keeps the clean name; any later block with a different shape gets a short content-hash suffix, so neither is silently mistyped:

1
export interface Hero {
2
/* the first shape */
3
}
4
export interface Hero_9C7E4012 {
5
/* the differing shape */
6
}

The suffix is derived from the block's fields, so the same schema always produces the same name across regenerations — it only changes when the block's fields change. If you import a hashed interface and later change that block, you'll get a type error that points you at the change instead of silently receiving the wrong shape.

To choose the name yourself and avoid the hash, set an explicit interfaceName on the block — explicit names are always used verbatim. This is the recommended fix when two blocks intentionally share a slug, or when you want a stable, human-readable name to import.

Using your types

Now that your types have been generated, Payload's Local API will now be typed. It is common for users to want to use this in their frontend code, we recommend generating them with Payload and then copying the file over to your frontend codebase. This is the simplest way to get your types into your frontend codebase.

Adding an npm script

Payload will automatically try and locate your config, but might not always be able to find it. For example, if you are working in a /src directory or similar, you need to tell Payload where to find your config manually by using an environment variable. If this applies to you, you can create an npm script to make generating your types easier.

To add an npm script to generate your types and show Payload where to find your config, open your package.json and update the scripts property to the following:

1
{
2
"scripts": {
3
"generate:types": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
4
},
5
}

Now you can run pnpm generate:types to easily generate your types.

Was this page helpful?

Next

TypeScript Plugin (Experimental)