Astro Actions 允许在服务端定义带类型约束的操作,并由框架自动生成客户端调用入口。按官方推荐方式使用,客户端通常能获得与 defineAction 定义一致的类型提示。但在实际项目里,只要导入路径偏离了 Astro 提供的虚拟模块,或者 handler 的返回值没有被显式标注,TypeScript 在客户端就会把参数推断成 any,返回值也可能变成一堆毫无约束的联合类型。这个问题在配合 zod 做参数校验时尤其常见,因为不少开发者误以为 zod schema 本身就足够驱动跨端类型推导。

一、类型链路为什么在客户端调用时断裂
Astro Actions 的类型推导依赖两个独立的上下文:服务端的 defineAction 定义,以及客户端从 astro:actions 虚拟模块导入的 actions 代理。两者之间的类型关联并不是靠简单 import 原始 actions 文件来完成的。Astro 在构建时会扫描 src/actions 下的定义,生成一份专门针对客户端运行时的模块声明。如果你在客户端组件中直接写了 import { server } from '../../actions',就等于绕过了这份类型映射,TypeScript 只能看到普通对象上的方法签名,而那个签名很可能没有保留完整的输入输出类型。
另一个容易忽略的点是 defineAction 的泛型推断本身并不是万能。它的 input 字段接受 zod schema,内部通过 z.infer 推导输入类型;输出类型则依赖 handler 的返回表达式。如果 handler 内部存在多个 return 分支,或者返回了一个通过复杂计算得到的对象,TypeScript 可能推断出过宽的联合类型。再加上 zod 中 z.string() 在默认情况下推导为 string,但如果你用了 z.coerce.number() 或 z.preprocess,输入类型与输出类型会进一步分化。此时客户端拿到的参数类型可能与你实际传入的数据不一致,编译期报错时好时坏。
下面先看一个最常见的错误组合:
// 服务端定义:src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
createPost: defineAction({
input: z.object({
title: z.string(),
content: z.string(),
}),
handler: async (input) => {
return { id: 1, title: input.title };
},
}),
};
// 客户端错误导入:组件中
import { server } from '../../actions';
const result = await server.createPost({ title: 'Hi', content: 'Body' });
// result 可能被推断为 any,参数也可能丢失具体结构
这种写法看似合理,但由于绕过了 astro:actions 虚拟模块,类型推导链已经被打断。下面分别从导入路径、显式标注、schema 类型分离三个方向来修复。
二、修复方案一:重写客户端导入路径
要恢复客户端类型提示,第一步必须使用 Astro 提供的标准入口 astro:actions。该模块导出一个 actions 对象,它与服务端的 server 对象一一对应,但类型签名由 Astro 在编译期重新生成。客户端组件中应该这样导入:
import { actions } from 'astro:actions';
const result = await actions.createPost({
title: 'Hello',
content: 'World',
});
这段代码里的 actions.createPost 不再是普通函数,而是一个经过编译期包装的客户端调用函数。Astro 会根据服务端定义的 input 和 output 为它生成精确类型。此时你在编辑器中输入 actions.createPost( 就能看到参数对象的完整提示,返回值也会被正确推断。比如上面例子中,result 会被推断为 { id: number; title: string }。
如果团队里有多个入口文件,或者在 monorepo 中通过相对路径共享 actions 类型,建议在客户端侧统一封装一个 client.ts 文件,从 astro:actions 导入 actions 后按需导出。这样可以避免其他成员继续使用错误的导入路径,也方便在类型层面做二次收窄。但要注意,不要在客户端代码里重新声明或包装 defineAction,那只会把类型再次搞乱。
三、修复方案二:显式标注 handler 的输入输出类型
即使使用了正确的 astro:actions 入口,有些场景下类型仍然可能偏宽。例如 handler 内部调用了其他服务,返回类型经过多层转换,或者输入 schema 用了 z.union、z.discriminatedUnion 等较复杂结构。此时最好显式给 handler 的参数和返回类型加上标注,而不仅仅依赖 zod 的自动推导。
可以先把 zod schema 提取成常量,再用 z.infer 生成输入类型,同时单独定义输出类型:
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
const createPostSchema = z.object({
title: z.string(),
content: z.string(),
});
type CreatePostInput = z.infer<typeof createPostSchema>;
type CreatePostOutput = {
id: number;
title: string;
};
export const server = {
createPost: defineAction({
input: createPostSchema,
handler: async (input: CreatePostInput): Promise<CreatePostOutput> => {
// input 的类型是 CreatePostInput
return {
id: 1,
title: input.title,
};
},
}),
};
这样做有两个好处。第一,handler 内部写业务逻辑时,input 会直接获得明确的字段提示,不会变成 any;第二,defineAction 在生成客户端类型时,不再需要从 zod schema 和返回表达式两头去猜,而是可以直接沿用你给出的 CreatePostInput 和 CreatePostOutput。即使 Astro 版本升级导致某个推断细节变化,显式标注仍然能保持稳定。
如果项目里 action 数量很多,且每个 action 都重复定义 type,可以考虑在 src/actions/types.ts 中集中维护这些输入输出类型。这样客户端代码可以通过 import type 引入,保持类型单一来源。但注意 import type 必须使用类型路径,避免在运行时引入服务端模块。
四、修复方案三:用 z.input 与 z.output 统一导出类型
zod 内置了 z.input 和 z.output 两个工具类型,可以分别提取 schema 的输入类型和输出类型。对于使用了 z.coerce、z.default 或 z.preprocess 的 schema,这两者并不相同。比如 z.coerce.number() 的输入类型是 string | number,输出类型才是 number。如果客户端调用时传入的是字符串,编译期应该检查输入类型;而 handler 内部拿到的已是转换后的输出类型。日志打印时如果把这两者混淆,就会出现类型不一致。
可以定义一个导出结构,把每个 action 的输入输出类型绑定在服务器对象旁边,供客户端引用:
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
const updateSettingsSchema = z.object({
userId: z.coerce.number(),
theme: z.string().default('light'),
});
export type UpdateSettingsInput = z.input<typeof updateSettingsSchema>;
export type UpdateSettingsOutput = z.output<typeof updateSettingsSchema>;
export const server = {
updateSettings: defineAction({
input: updateSettingsSchema,
handler: async (input) => {
// 这里 input.theme 已经是 string,并且有默认值
return { success: true, theme: input.theme };
},
}),
};
客户端调用时,actions.updateSettings 的参数类型会被推断为 UpdateSettingsInput,而不是 UpdateSettingsOutput。如果你的操作需要先对输入做转换,再返回给客户端,保持这两个类型的区分可以避免很多隐蔽的编译错误。尤其在表单提交场景中,表单控件给出的值往往是字符串,而服务端需要数字或布尔值,z.coerce 的输入类型会自动放宽,但客户端仍然需要看到真实的输入类型。
最后,如果发现某个 action 的类型无论如何推导都不对,可以在客户端临时用 typeof actions.someAction 查看实际签名的变化,再回退到服务端定义处逐一排查。Astro 的类型系统并不复杂,绝大多数情况都是导入路径或类型标注缺失导致的。
Astro ActionsTypeScript类型定义客户端调用类型修改时间:2026-09-18 12:32:44