server-actions
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseServer Actions
Server Actions
Naming Conventions
命名规范
- Name: — e.g.,
<verb><Resource>Action,createShortUrlActiondeleteFolderAction - Always a single export per file (the action function itself).
- 命名格式:— 例如:
<动词><资源>Action、createShortUrlActiondeleteFolderAction - 每个文件仅导出一个内容(即动作函数本身)。
Anatomy of a Server Action
Server Action 的结构
Every action MUST follow this strict structure with inputs validated by Zod and domain validation via Value Objects:
unknowntypescript
"use server"; // (1) Always first line
import { z } from "zod";
import { assertNever } from "@/shared/domain/utils/assertNever";
import { err, ok } from "neverthrow";
import { SessionError, type GetSession } from "@/auth/getSession";
export type MyActionErrorCode =
| "NOT_AUTHENTICATED"
| "INVALID_INPUT"
| "INVALID_ID"
| "NOT_FOUND"
| "UNEXPECTED_ERROR";
// (2) Exported types. MUST return branded types, NEVER Value Objects or Entities explicitly.
// Some client components need validated data; since we use Result<T,E>, components cannot throw errors.
export type MyActionResponse =
| { success: true; data: BrandedUUID }
| { success: false; error: MyActionErrorCode };
// (3) Zod Schema for incoming unknown parameters
// Zod CAN validate native/primitive types (e.g., `z.string()`, `z.number()`, `z.boolean()`) and specific common formats (e.g., `z.uuid()`, `z.email()`, `z.url()`).
// You MUST NOT use Zod for specific business logic boundaries like checking if a number is greater than/less than a specific amount, or specific lengths.
// Those specific business rules strictly belong in Value Objects.
// Zod validation returns a generic "INVALID_INPUT", while VO validation returns descriptive, domain-specific errors.
const formDataSchema = z.object({
id: z.uuid(),
});
// (4) Map domain errors exhaustively to action error codes
function handleMyUseCaseErrors(error: MyUseCaseErrors): MyActionErrorCode {
if (error instanceof NotFoundError) return "NOT_FOUND";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR";
return assertNever(error); // Prevents missing union members
}
// (5) The action function ALWAYS takes `unknown` to ensure type-safety at runtime
export async function myAction(rawData: unknown): Promise<MyActionResponse> {
// zod parsing → vo validation → getSession construction → use case → return
}每个动作必须遵循以下严格结构,使用Zod验证未知输入,并通过值对象进行领域验证:
typescript
"use server"; // (1) 始终放在第一行
import { z } from "zod";
import { assertNever } from "@/shared/domain/utils/assertNever";
import { err, ok } from "neverthrow";
import { SessionError, type GetSession } from "@/auth/getSession";
export type MyActionErrorCode =
| "NOT_AUTHENTICATED"
| "INVALID_INPUT"
| "INVALID_ID"
| "NOT_FOUND"
| "UNEXPECTED_ERROR";
// (2) 导出类型。必须返回品牌类型,绝不能显式返回值对象或实体。
// 部分客户端组件需要验证后的数据;由于我们使用Result<T,E>,组件不会抛出错误。
export type MyActionResponse =
| { success: true; data: BrandedUUID }
| { success: false; error: MyActionErrorCode };
// (3) 用于验证未知入参的Zod Schema
// Zod可以验证原生/原始类型(如`z.string()`、`z.number()`、`z.boolean()`)以及特定通用格式(如`z.uuid()`、`z.email()`、`z.url()`)。
// 请勿使用Zod验证特定业务逻辑边界,比如检查数字是否大于/小于特定值,或特定长度。
// 这些特定业务规则严格属于值对象的职责范围。
// Zod验证返回通用的"INVALID_INPUT",而值对象验证会返回更具描述性的领域特定错误。
const formDataSchema = z.object({
id: z.uuid(),
});
// (4) 将领域错误全面映射为动作错误码
function handleMyUseCaseErrors(error: MyUseCaseErrors): MyActionErrorCode {
if (error instanceof NotFoundError) return "NOT_FOUND";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR";
return assertNever(error); // 防止遗漏联合成员
}
// (5) 动作函数始终接收`unknown`参数,以确保运行时的类型安全
export async function myAction(rawData: unknown): Promise<MyActionResponse> {
// zod解析 → 值对象验证 → 构建getSession → 用例执行 → 返回结果
}1. Types
1. 类型
Response Type
响应类型
Always a discriminated union over . The field MUST always be typed using the exported error code union — never .
When returning data on , you MUST return branded primitive types (like ), never rich class instances like Value Objects or Entities, as they cannot cross the Server ↔ Client boundary.
successerrorstringsuccess: trueBrandedUUIDtypescript
// Simple: single error field returning a Branded Type
export type CreateFolderActionResult =
| { success: true; folderId: BrandedUUID }
| { success: false; error: CreateFolderActionErrorCode };
// Form actions: multiple error codes possible using an array
export type CreateShortUrlActionResponse =
| { success: true }
| { success: false; errors: CreateShortUrlActionErrorCodes[] };必须是基于的区分联合类型。字段必须始终使用导出的错误码联合类型进行定义 — 绝不能是类型。
当时返回数据,必须返回品牌化原始类型(如),绝不能返回值对象或实体等复杂类实例,因为它们无法跨服务器↔客户端边界传递。
successerrorstringsuccess: trueBrandedUUIDtypescript
// 简单场景:单个错误字段,返回品牌类型
export type CreateFolderActionResult =
| { success: true; folderId: BrandedUUID }
| { success: false; error: CreateFolderActionErrorCode };
// 表单动作:可能返回多个错误码,使用数组
export type CreateShortUrlActionResponse =
| { success: true }
| { success: false; errors: CreateShortUrlActionErrorCodes[] };Error Code Type
错误码类型
Use a string literal union (not an enum). Export it. Always include and .
"INVALID_INPUT""UNEXPECTED_ERROR"使用字符串字面量联合类型(而非枚举)。导出该类型。必须包含和。
"INVALID_INPUT""UNEXPECTED_ERROR"2. Auth and unknown
Zod Parsing
unknown2. 认证与unknown
参数的Zod解析
unknownActions MUST NOT natively type the payload via TypeScript signatures because they are easily skipped at runtime. They MUST take and use Zod.
rawData: unknownZod validations MUST remain primitive (e.g., ). Detailed validation is deferred to Value Objects so specific errors can be returned for better client-side UX.
z.string()typescript
export async function createThingAction(rawData: unknown): Promise<CreateThingResponse> {
const paramsParse = formDataSchema.safeParse(rawData);
if (!paramsParse.success) return { success: false, error: "INVALID_INPUT" };
// Proceed to Value Object instantiation...
}动作不能通过TypeScript签名原生定义负载类型,因为运行时很容易绕过这种校验。动作必须接收参数并使用Zod进行验证。
rawData: unknownZod验证必须仅针对原始类型(如)。详细验证需延迟到值对象中进行,以便返回更具体的错误,提升客户端用户体验。
z.string()typescript
export async function createThingAction(rawData: unknown): Promise<CreateThingResponse> {
const paramsParse = formDataSchema.safeParse(rawData);
if (!paramsParse.success) return { success: false, error: "INVALID_INPUT" };
// 继续值对象实例化...
}3. Session Construction & Inversion of Control
3. Session构建与控制反转
You MUST NOT check auth at the very top of the action. Instead, construct a function inside the action and pass it to the Use Case to be executed when the Use Case requires it.
getSession: GetSessiontypescript
const getSession: GetSession = async () => {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return err(new SessionError());
const userIdResult = UUID.from(session.user.id);
if (userIdResult.isErr()) return err(new SessionError());
return ok({ userId: userIdResult.value });
};不能在动作的最顶部检查认证状态。相反,应在动作内部构建函数,并将其传递给用例,在用例需要时执行。
getSession: GetSessiontypescript
const getSession: GetSession = async () => {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return err(new SessionError());
const userIdResult = UUID.from(session.user.id);
if (userIdResult.isErr()) return err(new SessionError());
return ok({ userId: userIdResult.value });
};4. Value Object (VO) Validation
4. 值对象(VO)验证
Batch validation pattern (form actions with multiple fields)
批量验证模式(含多个字段的表单动作)
Collect all domain errors explicitly so TS narrows all Results. This is why Zod is only used for structural validation; Value Objects return more specific errors for better UX.
neverthrowtypescript
const workspaceIdResult = UUID.from(formData.workspaceId);
const nameResult = ShortUrlName.from(formData.name);
const errors: CreateShortUrlActionErrorCodes[] = [];
if (workspaceIdResult.isErr()) errors.push("INVALID_WORKSPACE_ID");
if (nameResult.isErr()) errors.push("INVALID_NAME");
// Guard ensures narrow types
if (
errors.length > 0 ||
workspaceIdResult.isErr() ||
nameResult.isErr()
) {
return { success: false, errors };
}
// All are now Ok -> `.value` avoids _unsafeUnwrap!
const result = await serviceContainer.foo.create.execute({
workspaceId: workspaceIdResult.value,
name: nameResult.value,
getSession
});显式收集所有领域错误,以便TypeScript窄化所有结果。这就是为什么Zod仅用于结构验证;值对象会返回更具体的错误,以提升用户体验。
neverthrowtypescript
const workspaceIdResult = UUID.from(formData.workspaceId);
const nameResult = ShortUrlName.from(formData.name);
const errors: CreateShortUrlActionErrorCodes[] = [];
if (workspaceIdResult.isErr()) errors.push("INVALID_WORKSPACE_ID");
if (nameResult.isErr()) errors.push("INVALID_NAME");
// 守卫确保类型窄化
if (
errors.length > 0 ||
workspaceIdResult.isErr() ||
nameResult.isErr()
) {
return { success: false, errors };
}
// 所有结果均为Ok → 使用`.value`避免_unsafeUnwrap!
const result = await serviceContainer.foo.create.execute({
workspaceId: workspaceIdResult.value,
name: nameResult.value,
getSession
});5. Use-Case Error Mapping with assertNever
assertNever5. 使用assertNever
进行用例错误映射
assertNeverWhen a use case fails, strictly map its specific errors through a dedicated function relying on .
will throw a TypeScript error if you update the Use Case to emit a new Error class but forget to update the Action.
assertNeverassertNevertypescript
function handleCreateShortUrlErrors(error: CreateShortUrlErrors): CreateShortUrlActionErrorCodes {
if (error instanceof UserNotFoundError) return "USER_NOT_FOUND";
if (error instanceof WorkspaceNotFoundError) return "WORKSPACE_NOT_FOUND";
if (error instanceof PathAlreadyInUseError) return "PATH_ALREADY_IN_USE";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR"; // Must explicitly map!
// Enforces TS compile-time error if new use-case errors are unhandled
return assertNever(error);
}当用例执行失败时,必须通过依赖的专用函数严格映射其特定错误。
如果更新用例以抛出新的错误类但忘记更新动作,会触发TypeScript错误。
assertNeverassertNevertypescript
function handleCreateShortUrlErrors(error: CreateShortUrlErrors): CreateShortUrlActionErrorCodes {
if (error instanceof UserNotFoundError) return "USER_NOT_FOUND";
if (error instanceof WorkspaceNotFoundError) return "WORKSPACE_NOT_FOUND";
if (error instanceof PathAlreadyInUseError) return "PATH_ALREADY_IN_USE";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR"; // 必须显式映射!
// 如果有未处理的新用例错误,会强制触发TS编译时错误
return assertNever(error);
}Infrastructure errors → always UNEXPECTED_ERROR
基础设施错误 → 始终映射为UNEXPECTED_ERROR
Errors from the infrastructure layer (database failures, , network issues) MUST always be collapsed to . NEVER expose internals.
RepositoryError"UNEXPECTED_ERROR"基础设施层的错误(数据库故障、、网络问题)必须始终映射为。绝不能暴露内部实现细节。
RepositoryError"UNEXPECTED_ERROR"6. Use Case Execution
6. 用例执行
Always use — never instantiate use cases manually:
serviceContainertypescript
import { serviceContainer } from "@/shared/infrastructure/bootstrap";
const result = await serviceContainer.folders.createFolder.execute({ ... });必须始终使用 — 绝不能手动实例化用例:
serviceContainertypescript
import { serviceContainer } from "@/shared/infrastructure/bootstrap";
const result = await serviceContainer.folders.createFolder.execute({ ... });Complete Example
完整示例
typescript
"use server";
import { z } from "zod";
import { auth } from "@/auth/auth";
import { headers } from "next/headers";
import { err, ok } from "neverthrow";
import { serviceContainer } from "@/shared/infrastructure/bootstrap";
import { BrandedUUID, UUID } from "@/shared/domain/value-objects/uuid";
import { ShortUrlName } from "@/short-urls/domain/value-objects/shortUrlName";
import type { CreateShortUrlErrors } from "@/short-urls/use-cases/createShortUrl";
import { PathAlreadyInUseError } from "@/short-urls/domain/errors/pathAlreadyInUseError";
import { UserNotFoundError } from "@/users/domain/errors/userNotFoundError";
import { RepositoryError } from "@/shared/domain/errors/repositoryError";
import { assertNever } from "@/shared/domain/utils/assertNever";
import { GetSession, SessionError } from "@/auth/getSession";
export type CreateShortUrlActionErrorCode =
| "NOT_AUTHENTICATED"
| "INVALID_INPUT"
| "INVALID_NAME"
| "USER_NOT_FOUND"
| "PATH_ALREADY_IN_USE"
| "UNEXPECTED_ERROR";
export type CreateShortUrlActionResponse =
| { success: true; id: BrandedUUID } // MUST output primitives or branded primitive types.
| { success: false; error: CreateShortUrlActionErrorCode };
function handleCreateShortUrlActionErrors(error: CreateShortUrlErrors): CreateShortUrlActionErrorCode {
if (error instanceof UserNotFoundError) return "USER_NOT_FOUND";
if (error instanceof PathAlreadyInUseError) return "PATH_ALREADY_IN_USE";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR";
return assertNever(error);
}
const formDataSchema = z.object({
name: z.string(),
});
export async function createShortUrlAction(rawData: unknown): Promise<CreateShortUrlActionResponse> {
const parseResult = formDataSchema.safeParse(rawData);
if (!parseResult.success) {
return { success: false, error: "INVALID_INPUT" };
}
const nameResult = ShortUrlName.from(parseResult.data.name);
if (nameResult.isErr()) {
return { success: false, error: "INVALID_NAME" };
}
const getSession: GetSession = async () => {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return err(new SessionError());
const userIdResult = UUID.from(session.user.id);
if (userIdResult.isErr()) return err(new SessionError());
return ok({ userId: userIdResult.value });
}
const result = await serviceContainer.shortUrls.create.execute({
name: nameResult.value,
getSession
});
if (result.isErr()) {
return { success: false, error: handleCreateShortUrlActionErrors(result.error) };
}
// Value Objects MUST cross to the client using `.toBranded()`
return { success: true, id: result.value.getId().toBranded() };
}typescript
"use server";
import { z } from "zod";
import { auth } from "@/auth/auth";
import { headers } from "next/headers";
import { err, ok } from "neverthrow";
import { serviceContainer } from "@/shared/infrastructure/bootstrap";
import { BrandedUUID, UUID } from "@/shared/domain/value-objects/uuid";
import { ShortUrlName } from "@/short-urls/domain/value-objects/shortUrlName";
import type { CreateShortUrlErrors } from "@/short-urls/use-cases/createShortUrl";
import { PathAlreadyInUseError } from "@/short-urls/domain/errors/pathAlreadyInUseError";
import { UserNotFoundError } from "@/users/domain/errors/userNotFoundError";
import { RepositoryError } from "@/shared/domain/errors/repositoryError";
import { assertNever } from "@/shared/domain/utils/assertNever";
import { GetSession, SessionError } from "@/auth/getSession";
export type CreateShortUrlActionErrorCode =
| "NOT_AUTHENTICATED"
| "INVALID_INPUT"
| "INVALID_NAME"
| "USER_NOT_FOUND"
| "PATH_ALREADY_IN_USE"
| "UNEXPECTED_ERROR";
export type CreateShortUrlActionResponse =
| { success: true; id: BrandedUUID } // 必须输出原始类型或品牌化原始类型。
| { success: false; error: CreateShortUrlActionErrorCode };
function handleCreateShortUrlActionErrors(error: CreateShortUrlErrors): CreateShortUrlActionErrorCode {
if (error instanceof UserNotFoundError) return "USER_NOT_FOUND";
if (error instanceof PathAlreadyInUseError) return "PATH_ALREADY_IN_USE";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR";
return assertNever(error);
}
const formDataSchema = z.object({
name: z.string(),
});
export async function createShortUrlAction(rawData: unknown): Promise<CreateShortUrlActionResponse> {
const parseResult = formDataSchema.safeParse(rawData);
if (!parseResult.success) {
return { success: false, error: "INVALID_INPUT" };
}
const nameResult = ShortUrlName.from(parseResult.data.name);
if (nameResult.isErr()) {
return { success: false, error: "INVALID_NAME" };
}
const getSession: GetSession = async () => {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return err(new SessionError());
const userIdResult = UUID.from(session.user.id);
if (userIdResult.isErr()) return err(new SessionError());
return ok({ userId: userIdResult.value });
}
const result = await serviceContainer.shortUrls.create.execute({
name: nameResult.value,
getSession
});
if (result.isErr()) {
return { success: false, error: handleCreateShortUrlActionErrors(result.error) };
}
// 值对象必须通过`.toBranded()`传递到客户端
return { success: true, id: result.value.getId().toBranded() };
}Advanced Utility (New Projects): todo.ts
todo.ts高级工具(新项目):todo.ts
todo.tsIf you are rapidly building out the architecture and have Server Actions or Use Cases mocked up that are not fully built, import the utility (added in ) directly into your server action to safely halt compilation with or instead of placing random throws. All throws should eventually be cleared out into explicit results.
todo.tscore-utilities.mdtodo.panic("missing mapping")todo.unimplemented("WIP")neverthrow如果您正在快速搭建架构,且已模拟Server Actions或用例但尚未完全实现,可以直接将工具(在中添加)导入到Server Action中,使用或安全地终止编译,而不是随意抛出错误。所有抛出的错误最终都应替换为显式的结果。
todo.tscore-utilities.mdtodo.panic("缺失映射")todo.unimplemented("开发中")neverthrow