Skip to content

feat!: replace TypedUser with User and add AuthenticatedUser#17151

Open
AlessioGr wants to merge 19 commits into
mainfrom
feat/user-types-2
Open

feat!: replace TypedUser with User and add AuthenticatedUser#17151
AlessioGr wants to merge 19 commits into
mainfrom
feat/user-types-2

Conversation

@AlessioGr

@AlessioGr AlessioGr commented Jun 29, 2026

Copy link
Copy Markdown
Member

What

This completes the user-type cleanup planned for Payload 4.0:

  • UntypedUser was deprecated for removal in 4.0.
  • TypedUser was marked to be renamed to User in 4.0.

Previously, the public User type was the loose, deprecated UntypedUser, while TypedUser was the generated type for auth-enabled collections. ClientUser was also loose, and req.user did not include the runtime auth fields _strategy and _sid.

The types now have clear roles:

Type Purpose
User The generated user type for auth-enabled collections. This replaces TypedUser. Without generated types, it falls back to a documented shape containing Payload's built-in auth fields. Contains both read and write fields.
AuthenticatedUser User plus the optional runtime fields _strategy and _sid. Used by PayloadRequest.user, payload.auth(), auth strategy results, and auth internals.
ClientUser The type used by useAuth().user and me responses. It is now an alias of AuthenticatedUser

I'm considering replacing ClientUser in favor of just AuthenticatedUser in a separate PR.

Breaking changes

  • TypedUser has been removed. Use User.
  • UntypedUser has been removed. Use User for a user document, AuthenticatedUser for a signed-in request user, or ClientUser in client code.
  • User and ClientUser no longer have an [key: string]: any index signature. Custom auth-collection fields require generated types or an explicit augmented type.
  • The Local API user option is now User | null instead of the loose Document type for:
    • collection count, create, delete, duplicate, find, findByID, findDistinct, and update
    • collection and global version count, find, find-by-ID, and restore operations
    • global findOne and update
  • UserSession.createdAt is now optional and nullable: createdAt?: Date | null | string. This matches generated session types, but callers must handle a missing value.

AuthenticatedUser is assignable to User, so passing req.user to these Local API operations continues to work.

Other changes

  • Adds a strict untyped fallback containing all built-in user fields, without an index signature. A type test verifies that generated user types are assignable to this fallback.
  • Types payload.auth() and login results with the runtime auth fields, and uses AuthenticatedUser while login, me, refresh, and session code build signed-in users.
  • Fixes the session lookup ID type and updates session handling for nullable createdAt values.
  • Hardens refresh/session handling when a user or session is missing and avoids mutating the in-memory user's updatedAt solely to control database timestamps.
  • Updates Payload, the admin UI, Lexical, tests, and first-party plugins to use the new types:
    • plugin-mcp uses User for the authorized caller.
    • plugin-import-export removes obsolete req.user.user handling
    • plugin-multi-tenant explicitly casts accesses to plugin-defined user fields.
    • plugin-ecommerce adds UserWithCart for the optional reverse cart join that projects may
      define on their user collection.

Migration

- import type { TypedUser, UntypedUser } from 'payload'
+ import type { User } from 'payload'

Use User for stored/read user documents and AuthenticatedUser when code specifically receives the signed-in user from req.user, payload.auth(), or an auth strategy.


@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 201.84 KB ✅ No change
packages/payload/meta_index.json esbuild/index.js 1.40 MB ⚠️ +1.40 KB (+0.1%)
packages/payload/meta_shared.json esbuild/exports/shared.js 192.89 KB ⚠️ +110 B (+0.1%)
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 285.18 KB ✅ No change
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 1.39 MB ⚠️ +3.23 KB (+0.2%)
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 18.65 KB ✅ No change
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▋ }}}$ 98.9%, 197.86 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.3%, 663 B
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.3%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.3%, 526 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 409 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████▎ }}}$ 69.1%, 964.27 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 43.88 KB
dist/collections/operations ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 42.68 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 16.00 KB
dist/auth/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.69 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.36 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.27 KB
dist/queues/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 12.63 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.57 KB
dist/bin/generateImportMap ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.82 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.21 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.07 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.80 KB
dist/index.js ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 7.77 KB
dist/hierarchy/utils ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.64 KB
dist/database/migrations ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 7.55 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 6.33 KB
dist/collections/endpoints ${{\color{Goldenrod}{ }}}$ 0.4%, 6.12 KB
dist/queues/config ${{\color{Goldenrod}{ }}}$ 0.4%, 5.59 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 5.43 KB
(other) ${{\color{Goldenrod}{ ███████▋ }}}$ 30.9%, 430.36 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████████████████▊ }}}$ 79.4%, 150.12 KB
dist/fields/validations.js ${{\color{Goldenrod}{ █▍ }}}$ 5.6%, 10.57 KB
dist/config/orderable ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 2.79 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 2.69 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.42 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 1.29 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 1.04 KB
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ }}}$ 0.4%, 794 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.4%, 779 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.4%, 713 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 651 B
dist/collections/config ${{\color{Goldenrod}{ }}}$ 0.3%, 570 B
dist/bin/generateImportMap ${{\color{Goldenrod}{ }}}$ 0.3%, 561 B
dist/auth/sessions.js ${{\color{Goldenrod}{ }}}$ 0.3%, 528 B
dist/fields/getFieldPaths.js ${{\color{Goldenrod}{ }}}$ 0.3%, 485 B
dist/utilities/appendDateTimezoneSelectFields.js ${{\color{Goldenrod}{ }}}$ 0.2%, 432 B
dist/utilities/getSafeRedirect.js ${{\color{Goldenrod}{ }}}$ 0.2%, 423 B
dist/utilities/deepMerge.js ${{\color{Goldenrod}{ }}}$ 0.2%, 413 B
(other) ${{\color{Goldenrod}{ █████▏ }}}$ 20.6%, 38.92 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▎ }}}$ 13.2%, 37.20 KB
dist/lexical/ui ${{\color{Goldenrod}{ ███ }}}$ 12.1%, 34.16 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ██▉ }}}$ 11.7%, 32.93 KB
dist/features/experimental_table ${{\color{Goldenrod}{ ██▍ }}}$ 9.7%, 27.22 KB
dist/features/link ${{\color{Goldenrod}{ █▋ }}}$ 6.7%, 18.82 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▍ }}}$ 5.9%, 16.58 KB
dist/features/upload ${{\color{Goldenrod}{ █▎ }}}$ 5.0%, 14.09 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.9%, 11.08 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▉ }}}$ 3.6%, 10.02 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.4%, 9.61 KB
dist/features/converters ${{\color{Goldenrod}{ ▊ }}}$ 3.0%, 8.36 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 8.12 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.6%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 5.08 KB
dist/features/lists ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 3.64 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 3.28 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.23 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.18 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 2.83 KB
dist/lexical/nodes ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 2.66 KB
(other) ${{\color{Goldenrod}{ █████████████████████▋ }}}$ 86.8%, 244.77 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ██████████▌ }}}$ 42.0%, 580.42 KB
dist/elements/Hierarchy ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 43.38 KB
dist/views/Version ${{\color{Goldenrod}{ ▌ }}}$ 2.0%, 27.45 KB
dist/elements/BulkUpload ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 26.93 KB
dist/views/HierarchyList ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 20.43 KB
dist/elements/Table ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 19.52 KB
dist/views/Dashboard ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.82 KB
dist/views/Edit ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.62 KB
dist/elements/WhereBuilder ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.41 KB
dist/forms/Form ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.89 KB
dist/fields/Relationship ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.60 KB
dist/fields/Blocks ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.09 KB
dist/fields/Upload ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 14.42 KB
dist/elements/QueryPresets ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 10.75 KB
dist/elements/FileManager ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.58 KB
dist/elements/ReactSelect ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 9.14 KB
dist/views/List ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.78 KB
dist/elements/PublishButton ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.77 KB
dist/elements/HTMLDiff ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.38 KB
dist/elements/UserMenu ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 8.17 KB
(other) ${{\color{Goldenrod}{ ██████████████▍ }}}$ 58.0%, 801.29 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████▋ }}}$ 30.9%, 5.57 KB
../../node_modules ${{\color{Goldenrod}{ ███▋ }}}$ 14.7%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ ██ }}}$ 8.4%, 1.51 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▊ }}}$ 7.3%, 1.32 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █▏ }}}$ 4.8%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ █ }}}$ 4.2%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ █ }}}$ 4.1%, 745 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.7%, 493 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 440 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▌ }}}$ 2.3%, 419 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.8%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 232 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 157 B
dist/utilities/handleBackToDashboard.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 152 B
dist/forms/Form ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 148 B
dist/utilities/abortAndIgnore.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 146 B
(other) ${{\color{Goldenrod}{ ██████████████��██▎ }}}$ 69.1%, 12.46 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.
@AlessioGr AlessioGr changed the title feat!: split user types into User, AuthenticatedUser and ClientUser Jun 29, 2026
if (!user) {
throw new Forbidden(args.req.t)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bunch of unsafe code discovered by properly typing the user

existingSession.expiresAt = new Date(now.getTime() + tokenExpInMs)

// Prevent updatedAt from being updated when only refreshing a session
user.updatedAt = null

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was wrong - updatedAt is a required field on the user. We should only do this for what we send to the updateOne operation => do it inline


/**
* @deprecated Use `TypedUser` instead. This will be removed in 4.0.
* Note: AuthenticatedUser still carries the write-only `password` from `User` (always `undefined` at runtime).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal note, does not show in jsdocs for users hovering over the type since it's a separate comment block

export type ClientUser = {
[key: string]: any
} & BaseUser
export type ClientUser = AuthenticatedUser

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's just an alias, I'm considering removing it in a separate PR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in favor of removal. Having multiple types that are identical is not helpful


export type UserSession = { createdAt: Date | string; expiresAt: Date | string; id: string }
export type UserSession = {
createdAt?: Date | null | string

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it compatible with our generated User type

const data = req.data
const payload = req.payload
const user = req.user
const user = req.user as null | UserWithCart

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plugin-ecommerce adds new fields to the user collection. The best way to handle this internally is a new UserWithCart type, which extends our user with whatever plugin-ecommerce adds

let currency: string = currenciesConfig.defaultCurrency
let cartID: DefaultDocumentIDType = data?.cartID
let cart = undefined
let cart: any = undefined

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already untyped on main. Fixing it properly is out of scope for this PR

}

if (!user && req.user) {
user = req?.user?.id ? req.user : req?.user?.user

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was incorrect and just there to hide an incorrectly written test.

Our tests were incorrectly passing the entire LoginResult to the plugin-import-export user:

export type LoginResult<TSlug extends AuthCollectionSlug> = {
  exp?: number
  token?: string
  user?: AuthRuntimeFields & DataFromCollectionSlug<TSlug>
}

This should have failed, but we typed the test as any and handled it in runtime by checking for user?.user, which should never exist or be handled.

@AlessioGr AlessioGr marked this pull request as ready for review June 29, 2026 20:25
@AlessioGr AlessioGr enabled auto-merge (squash) June 29, 2026 20:34
@AlessioGr AlessioGr requested a review from denolfe as a code owner June 29, 2026 22:19
@AlessioGr AlessioGr requested a review from DanRibbens as a code owner June 29, 2026 22:19
Comment thread docs/migration-guide/v4.mdx
@AlessioGr AlessioGr requested a review from DanRibbens June 30, 2026 19:51
export type ClientUser = {
[key: string]: any
} & BaseUser
export type ClientUser = AuthenticatedUser

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in favor of removal. Having multiple types that are identical is not helpful

},
),
[usersTenantsArrayFieldName]: (
((user as Record<string, unknown>)[usersTenantsArrayFieldName] as Record<

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is the type assertion needed as Record<string, unknown>? It can't just be User?

@AlessioGr AlessioGr Jun 30, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified example

const runtimePropertyNotStatic: string = 'myField'
const user: User = getUser()
const valueOfPropertyForUser = user[runtimePropertyNotStatic]

The previous user type had a [property: string]: any index signature so we were able to do that. The new one does not have this index signature, so doing user[string] will error. In plugin-ecommerce this meant asserting user to a new, strongly typed UserAsCart type.

But in this example, we do not know the name of the property, since it's not static like cart. So we have to weaken the type to Record<string, unknown>. This is a rare scenario, and we do not lose any type-safety by doing that.

Why did we have to remove [property: string]: any?

This was needed in order to make the generated user type compatible with (assignable to) the fallback user type we use internally. We have a new type test for that: generated User is assignable to the untyped fallback user type.

@AlessioGr AlessioGr requested a review from DanRibbens June 30, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

2 participants