我的博客
返回首页

类型共识

分类:0-blog
发布于:
阅读时间:16 分钟

这是一个非常经典的问题,特别是在使用 Drizzle、Prisma 这种强类型 ORM 时。前端组件(如

react-hook-form
)喜欢用
undefined
表示“未输入”,但数据库(PostgreSQL)只认
NULL

要彻底解决这种“三态(Value / Null / Undefined)”冲突,核心思路是在 API 边界层进行“归一化”处理


1. 核心约定:谁该用什么?

为了避免逻辑混乱,建议全团队达成以下类型共识:

  • 数据库(Database):只有

    Value
    NULL

  • 后端 API(Elysia/RPC):接收端将

    undefined
    视为“不更新”,将
    null
    视为“清空”。

  • 前端表单(Hook Form):初始值建议用

    ""
    null
    ,避免使用
    undefined
    导致组件从受控变为非受控。


2. 后端:Zod 预处理(彻底消除 Undefined)

在你的

contract
或后端验证层,利用 Zod 的
.preprocess()
.catchment()
将前端发来的
undefined
转换为数据库兼容的
null

TypeScript

// @repo/contract
export const siteConfigSchema = z.object({
  key: z.string(),
  // 核心逻辑:如果前端没传或者是 undefined,统一转为 null 存入数据库
  value: z.preprocess((val) => val === undefined ? null : val, z.string().nullable()),
  description: z.preprocess((val) => val === undefined ? null : val, z.string().nullable()),
  jsonValue: z.any().nullable(), 
});

3. 前端:表单初始化(彻底消除 Null)

React 里的

<input>
如果
value={null}
会报错。因此在
useEffect
重置表单时,要将数据库捞出来的
null
转回
undefined
或空字符串。

TypeScript

// 在 CreateSiteConfigModal 中
useEffect(() => {
  if (editingConfig) {
    form.reset({
      // 数据库回来的是 null,但表单需要 string 或 undefined
      key: editingConfig.key,
      value: editingConfig.value ?? "", 
      description: editingConfig.description ?? "", // 👈 Nullish Coalescing 是关键
      category: editingConfig.category ?? "general",
      jsonValue: editingConfig.jsonValue ?? {}, 
    });
  }
}, [editingConfig]);

4. 提交时:工具函数过滤

在调用

mutate
之前,写一个通用的工具函数,把表单里那些“空字符串”或者
undefined
重新映射回数据库喜欢的
null

TypeScript

// lib/utils.ts
export function sanitizeSubmitData<T extends Record<string, any>>(data: T): T {
  const sanitized = { ...data };
  Object.keys(sanitized).forEach((key) => {
    // 约定:前端的空字符串或 undefined,在入库前全部转为 null
    if (sanitized[key] =<mark> "" || sanitized[key] </mark>= undefined) {
      sanitized[key] = null as any;
    }
  });
  return sanitized;
}

// 使用:
const onSubmit = async (data: FormData) => {
  const cleanData = sanitizeSubmitData(data);
  await updateMutation.mutateAsync({ id, data: cleanData });
};

5. Drizzle 层面的防御

在你的

db/schema.ts
中,为那些允许为空的字段明确标记
.default(null)
或不加
.notNull()
,这样 Drizzle 在生成的 TS 类型中会自动包含
| null

TypeScript

export const siteConfigTable = p.pgTable("site_config", {
  description: p.text("description"), // 自动推导为 string | null
  jsonValue: p.jsonb("json_value").default({}), // 强制默认值避免 null
});

总结方案:

  1. 查询时

    DB (Null) -> API -> Frontend (String/Empty)

  2. 编辑时

    Frontend (Input value) -> API -> sanitize (Null) -> DB

  3. JSON 字段:对于

    jsonb
    字段,建议数据库给一个默认值
    {}
    (非空),这样可以省去前端大量的
    if (data.jsonValue)
    判断。

这样处理后,你的 TS 错误会减少 90% 以上,因为你人为地在“进入表单”和“离开表单”这两个关口做了数据转换。

你想让我帮你把这个

sanitizeSubmitData
工具函数集成到你现在的
CreateSiteConfigModal
提交逻辑中吗?

总结

  • 前端表单:使用空字符串

    ""
    承接
    null

  • 提交阶段:使用

    || null
    强制将空态转为
    null

  • 契约层:保留

    null
    ,它是数据库状态的真实反映。