我如何尝试改进 Zod 的模拟

如果您是前端工程师,您可能遇到过这样的情况:您需要在服务于该功能后端部分的 API 出现之前开始实现该功能。工程师通常会求助于模拟来实现并行开发(这意味着该功能的前端和后端部分都是并行开发的)。

然而,模拟也有一些缺点。第一个也是最明显的缺点是,模拟可能会偏离实际实现,导致它们不可靠。第二个问题是模拟通常很冗长;对于包含大量数据的模拟,可能不清楚某个模拟响应实际上是在模拟什么。

下面的数据是您可能在代码库中找到的一些数据的示例:

type Order = {
  orderId: string;
  customerInfo: CustomerInfo; // omitted these types for brevity
  orderDate: string;
  items: OrderItem[];
  paymentInfo: PaymentInfo;
  subtotal: number;
  shippingCost: number;
  tax: number;
  totalAmount: number;
  status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
  trackingNumber: string | null;
};

const mockOrders: Order[] = [
  {
    orderId: "ORD-2024-001",
    customerInfo: {
      id: "CUST-1234",
      name: "Alice Johnson",
      email: "alice.j@email.com",
      shippingAddress: {
        street: "123 Pine Street",
        city: "Portland",
        state: "OR",
        zipCode: "97201",
        country: "USA"
      }
    },
    orderDate: "2024-03-15T14:30:00Z",
    items: [
      {
        productId: "PROD-789",
        name: "Organic Cotton T-Shirt",
        quantity: 2,
        pricePerUnit: 29.99,
        color: "Navy",
        size: "M"
      },
      {
        productId: "PROD-456",
        name: "Recycled Canvas Tote",
        quantity: 1,
        pricePerUnit: 35.00,
        color: "Natural"
      }
    ],
    paymentInfo: {
      method: "credit_card",
      status: "completed",
      transactionId: "TXN-88776655"
    },
    subtotal: 94.98,
    shippingCost: 5.99,
    tax: 9.50,
    totalAmount: 110.47,
    status: "shipped",
    trackingNumber: "1Z999AA1234567890"
  },
  // Imagine more objects here, with various values changed...
];

我每天处理的数据大致如下。订单数组或某种以客户为中心的信息,具有嵌套值,可帮助填充包含各种信息的表格、弹出窗口和卡片。

作为负责维护严重依赖此类模拟的应用程序的工程师,您可能会问“响应模拟中的这个特定对象是什么?”。我经常发现自己浏览数百个与上述示例类似的示例,但不确定每个对象的用途是什么。

随着我对自己作为工程师的信心越来越强,我开始着手解决上述问题;如果每个模拟都能更轻松地显示其目的会怎样?如果工程师只需要编写他们打算模拟的行会怎样?

在摆弄一些代码和一个名为 Zod 的库时,我发现了以下名为 parse 的方法,它尝试根据已知类型验证传入的数据:

const stringSchema = z.string();

stringSchema.parse("fish"); // => returns "fish"
stringSchema.parse(12); // throws error

这是一个灵光闪现的时刻;Zod 文档中的这个小例子正是我一直在寻找的!如果 parse 方法可以接受一个值并返回它,那么如果我传入一个值,我就会得到它。我还已经知道我可以为 Zod 模式定义默认值。如果传递一个空对象会返回一个带有其值的完整对象会怎么样?瞧,它确实做到了;我可以在 Zod 模式上定义默认值,并返回默认值:

const UserSchema = z.object({
  id: z.string().default('1'),
  name: z.string().default('Craig R Broughton'),
  settings: z.object({
    theme: z.enum(['light', 'dark']),
    notifications: z.boolean()
  }).default({
    theme: 'dark',
    notifications: true,
  })
});

const user = UserSchema.parse({}) // returns a full user object

现在我有了生成对象的方法,但这仍然不是我想要的。我真正想要的是一种编写我“模拟”的确切行的方法。一个简单的解决方案可能看起来像:

const UserSchema = z.object({
id: z.string().default('1'),
name: z.string().default('Craig R Broughton'),
settings: z.object({
  theme: z.enum(['light', 'dark']),
  notifications: z.boolean()
}).default({
  theme: 'dark',
  notifications: true,
})
});


const user = UserSchema.parse({})
const overridenUser = {...user, ...{
  name: "My new name",
  settings: {}, // I would need to write every key:value for settings :(
} satisfies Partial>} // overrides the base object

然而,这有其自身的缺陷;如果我想要覆盖的值本身是一个对象或数组怎么办?然后我必须手动输入之前需要的每一行,才能使该功能继续工作并按预期进行模拟,这违背了我们正在进行的解决方案的目的。

很长一段时间以来,我只能做到这些,直到最近我又尝试改进上述内容。第一步是定义“API”;我希望我的用户如何与此功能交互?

const UserSchema = z.object({
  id: z.string().default('1'),
  name: z.string().default('Craig R Broughton'),
  settings: z.object({
    theme: z.enum(['light', 'dark']),
    notifications: z.boolean()
  }).default({
    theme: 'dark',
    notifications: true,
  })
});

const user = zodObjectBuilder({
  schema: UserSchema,
  overrides: { name: 'My new name', settings: { theme: 'dark' } } // setting is missing the notifications theme :(
}); // returns a full user object with the overrides

上述 API 允许用户指定他们选择的架构,然后提供适当的覆盖并返回用户对象!当然,我们希望正确考虑数组以及单个对象。为此,对传入的覆盖类型进行简单的类型检查就足够了:

// Some of the implementation of zodObjectBuilder
  if (overrides && Array.isArray(overrides)) {
    const objects: z.infer[] = []
    overrides.forEach((override) => {
      if (config.preserveNestedDefaults) {
        const base = buildDefaultObject(schema)
        const newObject = merge(base, override)
        objects.push(newObject)
      }
      else {
        const base = schema.parse({})
        objects.push({ ...base, ...override })
      }
    })
    return objects
  }

上面的代码实际上与之前的代码相同,但是现在它在内部封装了解析,因此用户不必手动执行解析或了解有关 Zods 解析方法的详细信息。您可能从阅读所包含的 if/else 语句中猜到了,我们还通过使用递归构建器函数解决了嵌套对象和数组的保存问题,该函数解析每个值并返回 Zod 架构中指定的默认值。

上面的内容有点难以理解,但结果是用户可以执行以下操作:

const UserSchema = z.object({
  id: z.string().default('1'),
  name: z.string().default('Craig R Broughton'),
  settings: z.object({
    theme: z.enum(['light', 'dark']),
    notifications: z.boolean()
  }).default({
    theme: 'dark',
    notifications: true,
  })
});

const user = zodObjectBuilder({
  schema: UserSchema,
  config: { preserveNestedDefaults: true },
  overrides: { name: 'My new name', settings: { theme: 'dark' } }
}); // returns a full user object with the overrides, including nested values!

当向构建器提供 `preserveNestedDefaults` 配置选项时,用户可以保留嵌套对象或数组中的键值对!这解决了用户覆盖不是字符串等原始类型的键的问题,而是一个更复杂的类型,并保留了所有值减去我们选择覆盖的值。

这篇文章已经读得够多了,所以让我们以我们辛勤工作的成果作为结尾。让我们重新回顾一下第一个模拟,以及如何使用 zodObjectBuilder 编写它。首先让我们定义我们的类型和默认值,并将生成的模式传递到 zodObjectBuilder 中:

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  state: z.string(),
  zipCode: z.string(),
  country: z.string()
}).default({
  street: "123 Pine Street",
  city: "Portland",
  state: "OR",
  zipCode: "97201",
  country: "USA"
});

const customerInfoSchema = z.object({
  id: z.string().regex(/^CUST-\d{4}$/),
  name: z.string().min(1),
  email: z.string().email(),
  shippingAddress: addressSchema
}).default({
  id: "CUST-1234",
  name: "Alice Johnson",
  email: "alice.j@email.com",
});

const paymentInfoSchema = z.object({
  method: z.enum(['credit_card', 'paypal']),
  status: z.enum(['completed', 'pending', 'failed']),
  transactionId: z.string()
}).default({
  method: 'credit_card',
  status: 'pending',
  transactionId: 'TXN-88776655'
});

const orderItemSchema = z.object({
  productId: z.string().regex(/^PROD-\d{3}$/),
  name: z.string().min(1),
  quantity: z.number().int().positive(),
  pricePerUnit: z.number().positive(),
  color: z.string().optional(),
  size: z.enum(['XS', 'S', 'M', 'L', 'XL', 'XXL']).optional(),
  variety: z.string().optional(),
  weight: z.enum(['8oz', '12oz', '16oz', '1lb']).optional()
}).default({
  productId: "PROD-001",
  name: "Sample Product",
  quantity: 1,
  pricePerUnit: 29.99,
  color: "Black",
  size: "M"
});

const generateOrderId = () => {
  const year = new Date().getFullYear();
  const randomNum = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
  return `ORD-${year}-${randomNum}`;
};

const orderSchema = z.object({
  orderId: z.string().regex(/^ORD-\d{4}-\d{3}$/).default(generateOrderId()),
  customerInfo: customerInfoSchema,
  orderDate: z.string().datetime().default(new Date().toISOString()),
  items: z.array(orderItemSchema).min(1).default([orderItemSchema.parse(undefined)]),
  paymentInfo: paymentInfoSchema,
  subtotal: z.number().positive().default(29.99),
  shippingCost: z.number().nonnegative().default(5.99),
  tax: z.number().nonnegative().default(3.00),
  totalAmount: z.number().positive().default(38.98),
  status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']).default('pending'),
  trackingNumber: z.string().default('1Z999AA1234567890')
});

const orderItem = zodObjectBuilder({
  schema: orderSchema,
});

上述实现将使用所有默认值返回单个对象!但我们可以做得更好,我们现在可以(借助一些重载定义和内部解析)创建对象数组,非常适合模拟 API 响应的用例:

const orders = zodObjectBuilder({
  schema: orderSchema, // Passing in that same schema from the previous example
  config: { preserveNestedDefaults: true },
  overrides: [
    { // Each object is a fully defined object with the default values! :)
      status: "delivered"
    },
    {
      status: "shipped"
    },
    {
      status: "pending"
    },
    {
      status: "processing"
    },
    {
      status: "cancelled"
    },
  ]
});

以上输出的订单数组将具有完整的默认值,并带有覆盖的交付状态!希望这可以演示 zodObjectBuilder 函数如何最大限度地减少基于可靠的类型安全模式创建新模拟所需的工作量。

通过这个小演示,我们已经结束了我的第一篇文章 :) 我希望你喜欢阅读这篇探索改进模拟的旅程。zodObjectBuilder 仍在构建中,但它很好地满足了我最小化模拟对象的需求。如果您想试用当前版本,您可以在 https://www.npmjs.com/package/@crbroughton/ts-utils 找到它,其中包含该函数。