webiny-js 代码规范:通过命名空间(Namespace)引用抽象类型的实践指南
webiny-js 代码规范:通过命名空间(Namespace)引用抽象类型的实践指南
导读
本文解读 webiny-js 开源仓库中 ai-context/code-style/reach-abstractions-through-the-namespace.md 所确立的一条核心 TypeScript 代码风格规则:在声明抽象的文件之外,一律通过抽象自身的命名空间(namespace)来引用其接口类型(Foo.Interface),而不是直接使用 IFoo 前缀接口。该规则作用于仓库中以 createAbstraction 构建的各类依赖抽象(如 AiSdkToolDefinition、HttpRouteDefinition),贯穿 packages/api-core、packages/event-handler-core 等核心包。读完本文,你将掌握该规范的完整约束、背后动机、源码级实现依据,以及它在真实代码中的落地形态,可直接用于审查与编写符合 webiny-js 风格的代码。
规则一句话概括
每个抽象(abstraction)都会声明一个接口(interface)和一个引用该接口的命名空间(namespace)。在声明它们的文件之外,引用时只能使用命名空间,即写作 Foo.Interface,而绝不使用命名空间所指向的 IFoo。
这一规则不是可选项,而是 webiny-js 代码风格审查的硬性约束,它同时回答了三个问题:
- 类型的外部公开名是什么(一个,而不是两个);
- 类型归属哪个抽象(通过命名空间一眼可辨);
- 抽象关联的其他类型住在哪里(namespace 内部,而不是各自散落的
I前缀导出)。
为什么「Bad」写法是错误的
文档给出了一个典型的反例(错误写法):
// Bad — 一个类型有俩公开名,且导入看不出它属于哪个抽象
import type { IAiSdkToolDefinition } from "@webiny/api-core/features/ai/index.js";
export const isReadOnly = (tool: IAiSdkToolDefinition): boolean => /* ... */;
这段代码有两个问题:
- 一个类型出现两个公开名字:
IAiSdkToolDefinition是接口本名,AiSdkToolDefinition.Interface是命名空间别名,二者指向同一个类型。两个名字同时出现在代码库中,必然导致漂移(drift)——有的文件导入前者、有的文件导入后者,读者必须反复核对二者是否为同一事物。 - 导入语句无法表明类型归属:
IAiSdkToolDefinition这串字符里没有任何信息告诉你它属于AiSdkToolDefinition这个抽象;而AiSdkToolDefinition.Interface读起来就是"这个抽象的接口"。
推荐的「Good」写法
// Good — 一个名字,读起来就是"这个抽象的接口"
import { AiSdkToolDefinition } from "@webiny/api-core/features/ai/index.js";
export const isReadOnly = (tool: AiSdkToolDefinition.Interface): boolean => /* ... */;
注意两个细节:
- 导入的是值(
import { AiSdkToolDefinition })而非import type,因为AiSdkToolDefinition本身既是一个运行时存在的抽象令牌(Abstraction实例),又承载了Interface类型别名,两者由同一个标识符提供,正好实现"一个名字"的诉求。 - 类型引用写作
AiSdkToolDefinition.Interface,这是 namespace 合并(declaration merging)的典型用法:createAbstraction返回的抽象令牌与export namespace AiSdkToolDefinition共享同一名称,类型与值天然绑定。
I 前缀接口为什么仍然必须存在
规则的落点在于消费方,但 I 前缀接口本体的存在是硬性前提。文档明确了两条理由:
export const AiSdkToolDefinition = createAbstraction<IAiSdkToolDefinition>("AiSdkToolDefinition");
createAbstraction需要它:抽象令牌必须绑定一个接口类型作为其类型参数。在 packages/api-core/src/features/ai/abstractions.ts#L147 中,AiSdkToolDefinition正是通过createAbstraction<IAiSdkToolDefinition>("AiSdkToolDefinition")创建;同理,packages/event-handler-core/src/features/http/abstractions.ts#L170 中HttpRouteDefinition = new Abstraction<IHttpRouteDefinition>("HttpRouteDefinition")。createAbstraction的底层实现见 packages/feature/src/createAbstraction.ts,它只是new Abstraction<T>(name)的一层封装。- TypeScript 声明产出(declaration emit)需要它被导出:
Foo.Interface的命名空间引用最终要在.d.ts中解析到IFoo,因此IFoo必须从声明它的文件中导出,否则AiSdkToolDefinition.Interface无法被下游类型检查。
也就是说:IFoo 的全部职责就是"被抽象绑定 + 被命名空间引用",仅此而已。它不是给调用方用的公开名。
不要在包的入口再次导出 I 前缀接口
命名空间里已经有了 Interface,那么包的 index.ts 就不得重复导出 I 前缀接口:
// Bad — index.ts
export { AiSdkToolDefinition } from "./abstractions.js";
export type { IAiSdkToolDefinition } from "./abstractions.js"; // 同一个类型,导出了两次
文档点名表扬了 event-handler-core 的做法。查看其实际代码:
- packages/event-handler-core/src/exports/api.ts 只导出
HttpRouteDefinition与HttpRouteHandler两个抽象令牌:export { HttpRouteDefinition, HttpRouteHandler } from "~/features/http/abstractions.js"; - 而
IHttpRouteDefinition、IHttpRoute等I前缀接口仅在包内部可见,定义在 packages/event-handler-core/src/features/http/abstractions.ts,包外无法也不应直接导入。
同样的模式也落实在 api-core 的 AI 功能包上。packages/api-core/src/features/ai/index.ts 导出了 AiSdkToolDefinition、AiSdkToolHandler、AiSdkTools 等全部抽象令牌,而对 I 前缀类型只导出了 IAiConnection、IAiConnectionInline 这类没有命名空间归宿的纯数据类型,IAiSdkToolDefinition 这类抽象接口一律被排除在入口之外。
如果 index.ts 同时导出两种写法,调用方就会在"用 AiSdkToolDefinition.Interface 还是 IAiSdkToolDefinition"之间随意二选一,导致同类型双名并存、命名漂移,阅读者必须逐一比对两个名字是否指向同一个类型——这正是本规则要消灭的认知负担。
命名空间是抽象相关类型的"家"
I 前缀接口只该有一个(供抽象绑定),但一个抽象往往不止一个关联类型。命名空间的另一大价值,就是把这些相关类型收拢在同一处,而不是让每个关联类型都各自再配一个 I 导出:
export namespace AiSdkToolDefinition {
export type Interface<TInput = any> = IAiSdkToolDefinition<TInput>;
export type Annotations = IAiSdkToolAnnotations;
}
这段代码取自 packages/api-core/src/features/ai/abstractions.ts#L158-L161,是仓库的真实实现。它把以下信息统一收编进 AiSdkToolDefinition 命名空间:
Interface:抽象的核心契约,即IAiSdkToolDefinition<TInput>;Annotations:工具的行为提示注解类型IAiSdkToolAnnotations(readOnlyHint、destructiveHint、idempotentHint、openWorldHint,见同文件 L111-L120)。
消费方因此可以这样用,相关名字全部跟随抽象走:
import { AiSdkToolDefinition } from "@webiny/api-core/features/ai/index.js";
function describe(tool: AiSdkToolDefinition.Interface) {
const annotations: AiSdkToolDefinition.Annotations = tool.annotations ?? {};
// ...
}
而 AiSdkTools 的实现类正是以这种形式声明依赖的。查看 packages/api-core/src/features/ai/AiSdkTools.ts#L8-L12:
class AiSdkToolsImpl implements AiSdkToolsAbstraction.Interface {
constructor(
private definitions: AiSdkToolDefinition.Interface[],
private resolver: AiSdkToolHandlerResolver.Interface
) {}
在这个文件中,AiSdkToolDefinition.Interface、AiSdkToolHandlerResolver.Interface 均通过命名空间引用,I 前缀接口一次都没有出现。这印证了规则的"消费端"形态:即使是在同一个包内、紧邻 abstractions.ts 的实现文件中,也一律走命名空间,而不是 import type { IAiSdkToolDefinition }。
命名空间的类型参数:保留泛型
注意命名空间别名保留了泛型签名:
export type Interface<TInput = any> = IAiSdkToolDefinition<TInput>;
这与 IAiSdkToolDefinition<TInput = any>(abstractions.ts#L137)一一对应,确保通过 AiSdkToolDefinition.Interface<MyInput> 使用时泛型信息不丢失。在 AiSdkTools.ts 中,定义数组以 AiSdkToolDefinition.Interface<a href="https://link.gitcode.com/i/6a7fffe0c1ddea4d73d2e4d9fcc78b23" target="_blank">](默认 any)注入,getToolSet() 再逐一定义读取 name、description、inputSchema 并延迟构建 execute([AiSdkTools.ts#L14-L34),整个链条都保持对命名空间类型的一贯引用。
例外:纯数据类型直接导出,不进命名空间
本规则只适用于抽象接口。对于背后没有抽象、只是承载数据的普通数据类型(例如 AiModel、IAiConnection),它们没有可归属的命名空间,因此直接导出即可。
这一点同样能在源码中得到印证。在 packages/api-core/src/features/ai/abstractions.ts 中:
AiModel(L80-L85)是一个纯数据形状(providerId、providerName、modelId、modelName),没有I前缀,也没有命名空间,被入口文件直接export type;IAiConnection、IAiConnectionInline(L44-L51)同样是数据形状,虽然保留I前缀,但在 packages/api-core/src/features/ai/index.ts#L11 中被export type { IAiConnection, IAiConnectionInline, ... }直接导出——因为它们是纯数据,无处安放命名空间,也不会与任何抽象令牌同名造成双名漂移。
判断标准很简单:问一句"这个类型背后有没有一个 createAbstraction 出来的抽象令牌?" 有,就走命名空间;没有,就直接导出。
实战自查清单
在 webiny-js 仓库内编写或审查代码时,可按以下清单逐条核对:
- 消费端引用:在声明文件之外,类型一律写作
Foo.Interface(泛型抽象如AiSdkToolDefinition.Interface<TInput>保留泛型参数),禁止import type { IFoo }; - 声明端职责:
IFoo仅在abstractions.ts内部存在并被createAbstraction<IFoo>(...)绑定、被export namespace Foo { export type Interface = IFoo; }引用,不得承担其他公开职责; - 包入口导出:包的
index.ts只导出抽象令牌(Foo),绝不同时export type { IFoo };IFoo只作为模块内部类型可见; - 相关类型归位:抽象的行为注解、参数形状等相关类型放进
Foo命名空间(如AiSdkToolDefinition.Annotations),不要各自配一个I导出; - 纯数据例外:无抽象承载的普通数据类型(如
AiModel、IAiConnection)不受此规则约束,直接在入口导出。
参考实现与延伸阅读
- 规则原文:ai-context/code-style/reach-abstractions-through-the-namespace.md
- 抽象实现样板:packages/api-core/src/features/ai/abstractions.ts(
AiSdkToolDefinition及其命名空间) - 消费端样板:packages/api-core/src/features/ai/AiSdkTools.ts(
AiSdkToolDefinition.Interface[]依赖注入) - 包入口导出约束:packages/api-core/src/features/ai/index.ts
- 规范示范包:packages/event-handler-core/src/exports/api.ts 与 packages/event-handler-core/src/features/http/abstractions.ts(只导出
HttpRouteDefinition/HttpRouteHandler,IHttpRouteDefinition仅包内可见) createAbstraction底层:packages/feature/src/createAbstraction.ts
若想进一步了解 webiny-js 的完整代码风格体系,可继续阅读 ai-context/code-style/ 目录下的其他规范文档(如 one-public-function-per-file.md、no-backwards-compat.md),以及 ai-context/issues/ddb-direct-usage-in-base-packages.md 中对分层与抽象边界的讨论。