diff options
Diffstat (limited to '2026/03/21/小谈-NestJS-Zod')
| -rw-r--r-- | 2026/03/21/小谈-NestJS-Zod/index.html | 292 |
1 files changed, 292 insertions, 0 deletions
diff --git a/2026/03/21/小谈-NestJS-Zod/index.html b/2026/03/21/小谈-NestJS-Zod/index.html new file mode 100644 index 00000000..70f6b8b2 --- /dev/null +++ b/2026/03/21/小谈-NestJS-Zod/index.html @@ -0,0 +1,292 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> +<meta http-equiv="X-UA-Compatible" content="ie=edge"> + + <meta name="author" content="韩暮秋"> + + + <meta name="subtitle" content="暮秋小屋"> + + + <meta name="description" content="这里是暮秋小屋,思念和灵感的寄存处"> + + + <meta name="keywords" content="韩暮秋,MuqiuHan,'Muqiu Han', 'muqiu han', muqiuhan"> + + + + + <title> + + 小谈 NestJS Zod | + 暮秋小屋 + </title> + + + + <link rel="icon" href="/favicon.ico"> + + + + + <!-- stylesheets list from _config.yml --> + + <link rel="stylesheet" href="/css/style.css"> + + + + + <link rel="preload" href="/fonts/latin-base.woff2" as="font" type="font/woff2" crossorigin> + <link rel="preload" href="/fonts/cjk-common.woff2" as="font" type="font/woff2" crossorigin> + + + + <!-- scripts list from _config.yml --> + + <script + src="/js/menu.js"></script> + + + + + + <script + src="https://polyfill.alicdn.com/polyfill.js?features=es6"></script> + <script + id="MathJax-script" + async + src="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/mathjax/3.2.0/es5/tex-mml-chtml.js"></script> + + + + + <meta name="generator" content="Hexo 6.3.0"></head> + <body> + <div class="wrapper"> + + <div class="header"> + <div class="flex-container"> + <div class="header-inner"> + <div class="site-brand-container"> + <a href="/"> + + 暮秋小屋 + + </a> + </div> + <div id="menu-btn" class="menu-btn" onclick="toggleMenu()"> + 菜单 + </div> + <nav class="site-nav"> + <ul class="menu-list"> + + + <li class="menu-item"> + <a href="/"> + 主页 + </a> + </li> + + + + <li class="menu-item"> + <a href="/categories/gallery/"> + 日记本 + </a> + </li> + + + + <li class="menu-item"> + <a href="/tags/Medicine/"> + 泛医学 + </a> + </li> + + + + <li class="menu-item"> + <a href="/tags/Technique/"> + 计算机 + </a> + </li> + + + + <li class="menu-item"> + <a href="/tags/Life/"> + 生活 + </a> + </li> + + + + <li class="menu-item"> + <a href="/archives"> + 全部 + </a> + </li> + + + + <li class="menu-item"> + <a href="/about"> + 关于 + </a> + </li> + + + + <li class="menu-item"> + <a href="/search">搜索</a> + </li> + + </ul> + </nav> + </div> + </div> +</div> + + + <div class="main"> + <div class="flex-container"> + <article id="post"> + + + <div class="post-head"> + <div class="post-info"> + <div class="tag-list"> + + + <span class="post-tag"> + <a href="/tags/Technique/"> + Technique + </a> + </span> + + + </div> + <div class="post-title"> + + + 小谈 NestJS Zod + + + </div> + <span class="post-date"> + Mar 21, 2026 + </span> + </div> + <div class="post-img"> + + <div class="h-line-primary"></div> + + </div> +</div> + <div class="post-content"> + <p>NestJS Zod 理论上是可以成为一层真正的契约系统的:请求校验、类型推导、OpenAPI 生成、领域约束复用,前后端一致的输入语义,都在同一份定义里。</p> +<blockquote> +<p>领域约束是否要复用需要讨论,但只说技术上可行。</p> +</blockquote> +<p>最简单的用法是用 <code>z.object(...)</code> 定义 schema,用 <code>createZodDto(schema)</code> 生成 DTO,然后引入一个全局的 <code>ZodValidationPipe</code>。大概是这样:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { <span class="title class_">Module</span> } <span class="keyword">from</span> <span class="string">'@nestjs/common'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="variable constant_">APP_PIPE</span> } <span class="keyword">from</span> <span class="string">'@nestjs/core'</span>;</span><br><span class="line"><span class="keyword">import</span> { <span class="title class_">ZodValidationPipe</span> } <span class="keyword">from</span> <span class="string">'nestjs-zod'</span>;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Module</span>({</span><br><span class="line"> <span class="attr">providers</span>: [</span><br><span class="line"> {</span><br><span class="line"> <span class="attr">provide</span>: <span class="variable constant_">APP_PIPE</span>,</span><br><span class="line"> <span class="attr">useClass</span>: <span class="title class_">ZodValidationPipe</span></span><br><span class="line"> }</span><br><span class="line"> ]</span><br><span class="line">})</span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">AppModule</span> {}</span><br></pre></td></tr></table></figure> +<p>DTO 只是 schema 的包装:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> { createZodDto } <span class="keyword">from</span> <span class="string">'nestjs-zod'</span>;</span><br><span class="line"><span class="keyword">import</span> { z } <span class="keyword">from</span> <span class="string">'zod'</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> createItemSchema = z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">name</span>: z.<span class="title function_">string</span>().<span class="title function_">trim</span>().<span class="title function_">min</span>(<span class="number">1</span>),</span><br><span class="line"> <span class="attr">price</span>: z.<span class="title function_">number</span>().<span class="title function_">nonnegative</span>()</span><br><span class="line">});</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">class</span> <span class="title class_">CreateItemDto</span> <span class="keyword">extends</span> <span class="title class_ inherited__">createZodDto</span>(createItemSchema) {}</span><br></pre></td></tr></table></figure> +<p>这样运行时校验和 TypeScript 类型就共享同一份定义了。</p> +<hr> +<p>Zod 可以很简单的实现字符串非空、数字范围、枚举取值、对象结构、Discriminated Union。DU 比传统 DTO 写法强,因为不同 <code>type</code> 对应的字段集合可以天然收敛:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> requestSchema = z.<span class="title function_">discriminatedUnion</span>(<span class="string">'type'</span>, [</span><br><span class="line"> z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">type</span>: z.<span class="title function_">literal</span>(<span class="string">'A'</span>),</span><br><span class="line"> <span class="attr">payload</span>: z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">foo</span>: z.<span class="title function_">string</span>()</span><br><span class="line"> })</span><br><span class="line"> }),</span><br><span class="line"> z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">type</span>: z.<span class="title function_">literal</span>(<span class="string">'B'</span>),</span><br><span class="line"> <span class="attr">payload</span>: z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">bar</span>: z.<span class="title function_">number</span>()</span><br><span class="line"> })</span><br><span class="line"> })</span><br><span class="line">]);</span><br></pre></td></tr></table></figure> +<p>但真实业务的问题通常不止于单字段合法。我遇到的一些情况有:</p> +<ul> +<li>某个数组元素本身没问题,但数组整体不能重复</li> +<li>某个字段语法上合法,但必须属于当前 <code>recordType</code> 允许的字段集合</li> +<li>两个字段分别没问题,但组合起来语义冲突</li> +</ul> +<p>这类规则会比较严重的影响接口可维护性,它们的特点是 "结构正确,语义错误”,假设请求结构是这样的:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">{</span><br><span class="line"> <span class="attr">recordType</span>: <span class="string">'A'</span>,</span><br><span class="line"> <span class="attr">structuredData</span>: { ... },</span><br><span class="line"> <span class="attr">fieldExceptions</span>: [</span><br><span class="line"> { <span class="attr">fieldKey</span>: <span class="string">'x'</span> },</span><br><span class="line"> { <span class="attr">fieldKey</span>: <span class="string">'y'</span> }</span><br><span class="line"> ]</span><br><span class="line">}</span><br></pre></td></tr></table></figure> +<p>此处 <code>fieldExceptions</code> 的意思是 “标记 structuredData 中的某个字段在业务上的特殊情况”,例如在数据标注系统中,数据标注人员无法结构化录入某个字段。</p> +<p>单看每一项 <code>{ fieldKey: z.string(), reason?: z.string() }</code> 完全合法。但可能出现这种情况:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">fieldExceptions</span>: [</span><br><span class="line"> { <span class="attr">fieldKey</span>: <span class="string">'x'</span> },</span><br><span class="line"> { <span class="attr">fieldKey</span>: <span class="string">'x'</span> }</span><br><span class="line">]</span><br></pre></td></tr></table></figure> +<p>JSON 结构没问题,类型也没问题,但业务语义上明显重复了。NestJS Zod 处理这类问题的方法是直接在 schema 层面操作:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> withUniqueFieldExceptions<T <span class="keyword">extends</span> z.<span class="property">ZodTypeAny</span>>(<span class="attr">schema</span>: T) {</span><br><span class="line"> <span class="keyword">return</span> schema.<span class="title function_">superRefine</span>(<span class="function">(<span class="params">value, ctx</span>) =></span> {</span><br><span class="line"> <span class="keyword">const</span> fieldExceptions = (value <span class="keyword">as</span> {</span><br><span class="line"> <span class="attr">fieldExceptions</span>?: <span class="title class_">Array</span><{ <span class="attr">fieldKey</span>: <span class="built_in">string</span> }>;</span><br><span class="line"> }).<span class="property">fieldExceptions</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (!fieldExceptions) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">const</span> seen = <span class="keyword">new</span> <span class="title class_">Set</span><<span class="built_in">string</span>>();</span><br><span class="line"></span><br><span class="line"> <span class="keyword">for</span> (<span class="keyword">const</span> [index, item] <span class="keyword">of</span> fieldExceptions.<span class="title function_">entries</span>()) {</span><br><span class="line"> <span class="keyword">if</span> (seen.<span class="title function_">has</span>(item.<span class="property">fieldKey</span>)) {</span><br><span class="line"> ctx.<span class="title function_">addIssue</span>({</span><br><span class="line"> <span class="attr">code</span>: z.<span class="property">ZodIssueCode</span>.<span class="property">custom</span>,</span><br><span class="line"> <span class="attr">message</span>: <span class="string">`Duplicate field exception for <span class="subst">${item.fieldKey}</span>.`</span>,</span><br><span class="line"> <span class="attr">path</span>: [<span class="string">'fieldExceptions'</span>, index, <span class="string">'fieldKey'</span>]</span><br><span class="line"> });</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> seen.<span class="title function_">add</span>(item.<span class="property">fieldKey</span>);</span><br><span class="line"> }</span><br><span class="line"> });</span><br><span class="line">}</span><br></pre></td></tr></table></figure> +<p>如果把去重校验写在 service/command/query 中,系统中的其他任何组件(例如测试)都可能绕过去。但如果写在 schema 中,所有入口只要用的是这份 schema,就天然继承这个约束。</p> +<p>注意在这个 helper 中有一个细节:错误路径要详细到具体数组项。<code>path: ['fieldExceptions', index, 'fieldKey']</code> 这个写法可以让前端拿到错误后不只是知道请求失败,而是能知道哪一项重复/错误。对于复杂表单,这种可定位性比一条顶层报错有用。</p> +<p>另外我这里给出的 helper 主要向展示的是 schema 层面的约束能力,不是业务对象, <code>withUniqueFieldExceptions</code> 不是某个接口私有逻辑,而是一种可复用的 schema 装饰器模式。同理可以有 <code>withUniqueAttachmentChanges</code>、<code>withNonOverlappingRanges</code>、<code>withConsistentDateOrder</code>。这类 helper 一旦抽出来,schema 层就具备组合能力,业务约束可以像搭积木一样拼装。</p> +<p>沿着这个思路,我们可以进一步的表示 “<code>fieldExceptions[].fieldKey</code> 不应该是任意字符串,它必须属于当前 <code>recordType</code> 允许的字段集合”:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">createFieldExceptionSchema</span>(<span class="params"><span class="attr">fieldKeys</span>: [<span class="built_in">string</span>, ...<span class="built_in">string</span>[]]</span>) {</span><br><span class="line"> <span class="keyword">return</span> z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">fieldKey</span>: z.<span class="title function_">enum</span>(fieldKeys),</span><br><span class="line"> <span class="attr">reason</span>: z.<span class="title function_">string</span>().<span class="title function_">trim</span>().<span class="title function_">max</span>(<span class="number">500</span>).<span class="title function_">optional</span>()</span><br><span class="line"> });</span><br><span class="line">}</span><br></pre></td></tr></table></figure> +<p>然后在 <code>discriminatedUnion</code> 的每个分支里绑定自己的字段白名单:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> requestSchema = z.<span class="title function_">discriminatedUnion</span>(<span class="string">'recordType'</span>, [</span><br><span class="line"> <span class="title function_">withUniqueFieldExceptions</span>(</span><br><span class="line"> z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">recordType</span>: z.<span class="title function_">literal</span>(<span class="string">'A'</span>),</span><br><span class="line"> <span class="attr">structuredData</span>: z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">foo</span>: z.<span class="title function_">union</span>([z.<span class="title function_">number</span>(), z.<span class="title function_">string</span>(), z.<span class="title function_">null</span>()])</span><br><span class="line"> }).<span class="title function_">strict</span>(),</span><br><span class="line"> <span class="attr">fieldExceptions</span>: z.<span class="title function_">array</span>(<span class="title function_">createFieldExceptionSchema</span>([<span class="string">'foo'</span>])).<span class="title function_">default</span>([])</span><br><span class="line"> })</span><br><span class="line"> ),</span><br><span class="line"> <span class="title function_">withUniqueFieldExceptions</span>(</span><br><span class="line"> z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">recordType</span>: z.<span class="title function_">literal</span>(<span class="string">'B'</span>),</span><br><span class="line"> <span class="attr">structuredData</span>: z.<span class="title function_">object</span>({</span><br><span class="line"> <span class="attr">bar</span>: z.<span class="title function_">union</span>([z.<span class="title function_">number</span>(), z.<span class="title function_">string</span>(), z.<span class="title function_">null</span>()])</span><br><span class="line"> }).<span class="title function_">strict</span>(),</span><br><span class="line"> <span class="attr">fieldExceptions</span>: z.<span class="title function_">array</span>(<span class="title function_">createFieldExceptionSchema</span>([<span class="string">'bar'</span>])).<span class="title function_">default</span>([])</span><br><span class="line"> })</span><br><span class="line"> )</span><br><span class="line">]);</span><br></pre></td></tr></table></figure> +<p>这样 <code>recordType</code> 和合法字段天然绑定,不需要额外写一堆 if/else,错误会在 parse 阶段就暴露而不是进入业务流程后才抛异常。schema 在这里承担了类型分支的职责。</p> +<hr> +<p>但对于复杂输入的场景,需要注意分层,validator 和 normalizer 应该分开。schema 不一定负责把所有值变干净,但它应该负责把输入约束在一个可以被 normalizer 处理的范围内。比如数值输入,前端可能传 <code>12.3</code>、<code>"12.3"</code>、<code>null</code>,schema 可以先允许 <code>z.union([z.number(), z.string(), z.null()])</code>,parse 成功之后再进入统一的 normalizer。这种分层比在 schema 里直接做满所有 coercion 会更加可维护,因为它把两个问题拆开了:schema 负责数据能不能被系统处理,normalizer 负责进来的数据如何变成领域标准格式。这能避免 schema 逐渐膨胀成一个难以维护的黑盒。</p> +<p>运行时 schema 和文档 schema 可以适度分离。有些真实运行时 schema 很复杂,比如 DU、<code>superRefine</code>、动态字段白名单等,有些约束还依赖运行时上下文,这些对 OpenAPI JSON Spec 的生成不一定友好。可以考虑保留两套:<code>requestSchema</code> 真实运行时校验用,<code>requestDtoSchema</code> 专门服务 OpenAPI 的描述性 schema。controller 的 DTO 从 <code>requestDtoSchema</code> 生成,但真正执行业务前,再对原始 payload 走一次 <code>requestSchema.safeParse(...)</code>。</p> +<blockquote> +<p>文档可读性和运行时严谨性不必强行绑定在同一份 schema 表达能力上。如果一份 schema 同时满足两者当然最好,如果不能,优先保证运行时正确性。</p> +</blockquote> +<p>注意,虽然 Controller 已经有 <code>ZodValidationPipe</code>,但如果用 CQRS,那么在 command/query handler 最好还要再次 <code>safeParse</code> 一下,因为进入 handler 的 payload 未必只来自 HTTP,还可能来自 cron、queue consumer、internal dispatch、test fixture、script。如果 handler 是真正的业务入口,那它就应该自己守住边界。这不是重复,而是分层后更加清晰的职责,因为 Pipe 只保护 HTTP 入口,handler 内 <code>safeParse</code> 主要保护业务入口。</p> +<p>最后,直接对 schema 写测试:</p> +<figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">it</span>(<span class="string">'rejects duplicated field exceptions'</span>, <span class="function">() =></span> {</span><br><span class="line"> <span class="keyword">const</span> result = requestSchema.<span class="title function_">safeParse</span>({</span><br><span class="line"> <span class="attr">recordType</span>: <span class="string">'A'</span>,</span><br><span class="line"> <span class="attr">structuredData</span>: { <span class="attr">foo</span>: <span class="string">'1'</span> },</span><br><span class="line"> <span class="attr">fieldExceptions</span>: [</span><br><span class="line"> { <span class="attr">fieldKey</span>: <span class="string">'foo'</span> },</span><br><span class="line"> { <span class="attr">fieldKey</span>: <span class="string">'foo'</span> }</span><br><span class="line"> ]</span><br><span class="line"> });</span><br><span class="line"></span><br><span class="line"> <span class="title function_">expect</span>(result.<span class="property">success</span>).<span class="title function_">toBe</span>(<span class="literal">false</span>);</span><br><span class="line">});</span><br></pre></td></tr></table></figure> +<p>这种测试验证的是契约本身,不是某个 service 逻辑的分支,这种测试看起来更有价值也更清晰。</p> + +</div> + +<script> + window.onload = detectors(); +</script> + <div class="post-footer"> + <div class="h-line-primary"></div> + <nav class="post-nav"> + <div class="prev-item"> + + </div> + <div class="next-item"> + + <div class="icon arrow-right"></div> + <div class="post-link"> + <a href="/2026/03/16/Nginx-%E5%89%8D%E5%90%8E%E7%AB%AF%E5%90%8C%E5%9F%9F%E5%88%86%E6%B5%81/">Next</a> + </div> + + </div> + </nav> +</div> + + + <div class="post-comment"> + + + + + + + +</div> + + +</article> + </div> + </div> + + <div class="footer"> + <div class="flex-container"> + <div class="footer-text"> + + + 韩暮秋 | + + + 希望路过的人可以添点柴火让这里暖和点 + + </div> + </div> +</div> + + + </div> + + <script src="/js/mermaid-zoom.js"></script> + + </body> +</html> |
