refactor: type safe env

This commit is contained in:
Stephen Zhou
2026-02-06 11:15:19 +08:00
parent 59a9cbbf78
commit 7d34faaf74
16 changed files with 106 additions and 237 deletions

View File

@@ -1,5 +1,5 @@
import type { RemixiconComponentType } from '@remixicon/react'
import { z } from 'zod'
import * as z from 'zod'
export const InputTypeEnum = z.enum([
'text-input',

View File

@@ -1,6 +1,6 @@
import type { ZodNumber, ZodSchema, ZodString } from 'zod'
import type { BaseConfiguration } from './types'
import { z } from 'zod'
import * as z from 'zod'
import { BaseFieldType } from './types'
export const generateZodSchema = (fields: BaseConfiguration[]) => {

View File

@@ -1,4 +1,4 @@
import { z } from 'zod'
import * as z from 'zod'
const ContactMethod = z.union([
z.literal('email'),
@@ -22,10 +22,10 @@ export const UserSchema = z.object({
.min(3, 'Surname must be at least 3 characters long')
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
isAcceptingTerms: z.boolean().refine(val => val, {
message: 'You must accept the terms and conditions',
error: 'You must accept the terms and conditions',
}),
contact: z.object({
email: z.string().email('Invalid email address'),
email: z.email('Invalid email address'),
phone: z.string().optional(),
preferredContactMethod: ContactMethod,
}),

View File

@@ -1,6 +1,6 @@
import type { ZodSchema, ZodString } from 'zod'
import type { InputFieldConfiguration } from './types'
import { z } from 'zod'
import * as z from 'zod'
import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema'
import { InputFieldType } from './types'

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { z } from 'zod'
import * as z from 'zod'
import withValidation from '.'
describe('withValidation HOC', () => {

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { z } from 'zod'
import * as z from 'zod'
import withValidation from '.'
// Sample components to wrap with validation
@@ -65,7 +65,7 @@ const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => {
// Create validated versions
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
email: z.email('Invalid email'),
age: z.number().min(0).max(150),
})
@@ -371,7 +371,7 @@ export const ConfigurationValidation: Story = {
)
const configSchema = z.object({
apiUrl: z.string().url('Must be valid URL'),
apiUrl: z.url('Must be valid URL'),
timeout: z.number().min(0).max(30000),
retries: z.number().min(0).max(5),
debug: z.boolean(),
@@ -430,7 +430,7 @@ export const UsageDocumentation: Story = {
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-900">Usage Example</h4>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
{`import { z } from 'zod'
{`import * as z from "zod"
import withValidation from './withValidation'
// Define your component

View File

@@ -1,7 +1,7 @@
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { z } from 'zod'
import * as z from 'zod'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import Actions from './actions'
@@ -53,7 +53,7 @@ const createFailingSchema = () => {
issues: [{ path: ['field1'], message: 'is required' }],
},
}),
} as unknown as z.ZodSchema
} as unknown as z.ZodType
}
// ==========================================

View File

@@ -1,6 +1,6 @@
import type { TFunction } from 'i18next'
import type { SchemaOptions } from './types'
import { z } from 'zod'
import * as z from 'zod'
import { InputTypeEnum } from '@/app/components/base/form/components/field/input-type-select/types'
import { MAX_VAR_KEY_LENGTH } from '@/config'
import { PipelineInputVarType } from '@/models/pipeline'
@@ -41,49 +41,47 @@ export const createInputFieldSchema = (type: PipelineInputVarType, t: TFunction,
tooltips: z.string().optional(),
})
if (type === PipelineInputVarType.textInput || type === PipelineInputVarType.paragraph) {
return z.object({
return z.looseObject({
maxLength: z.number().min(1).max(TEXT_MAX_LENGTH),
default: z.string().optional(),
}).merge(commonSchema).passthrough()
}).extend(commonSchema.shape)
}
if (type === PipelineInputVarType.number) {
return z.object({
return z.looseObject({
default: z.number().optional(),
unit: z.string().optional(),
placeholder: z.string().optional(),
}).merge(commonSchema).passthrough()
}).extend(commonSchema.shape)
}
if (type === PipelineInputVarType.select) {
return z.object({
options: z.array(z.string()).nonempty({
message: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }),
}).refine(
return z.looseObject({
options: z.tuple([z.string()], z.string()).refine(
arr => new Set(arr).size === arr.length,
{
message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }),
},
),
default: z.string().optional(),
}).merge(commonSchema).passthrough()
}).extend(commonSchema.shape)
}
if (type === PipelineInputVarType.singleFile) {
return z.object({
return z.looseObject({
allowedFileUploadMethods: z.array(TransferMethod),
allowedTypesAndExtensions: z.object({
allowedTypesAndExtensions: z.looseObject({
allowedFileExtensions: z.array(z.string()).optional(),
allowedFileTypes: z.array(SupportedFileTypes),
}),
}).merge(commonSchema).passthrough()
}).extend(commonSchema.shape)
}
if (type === PipelineInputVarType.multiFiles) {
return z.object({
return z.looseObject({
allowedFileUploadMethods: z.array(TransferMethod),
allowedTypesAndExtensions: z.object({
allowedTypesAndExtensions: z.looseObject({
allowedFileExtensions: z.array(z.string()).optional(),
allowedFileTypes: z.array(SupportedFileTypes),
}),
maxLength: z.number().min(1).max(maxFileUploadLimit),
}).merge(commonSchema).passthrough()
}).extend(commonSchema.shape)
}
return commonSchema.passthrough()
return z.looseObject(commonSchema.shape)
}

View File

@@ -1,6 +1,6 @@
import type { ValidationError } from 'jsonschema'
import type { ArrayItems, Field, LLMNodeType } from './types'
import { z } from 'zod'
import * as z from 'zod'
import { draft07Validator, forbidBooleanProperties } from '@/utils/validators'
import { ArrayType, Type } from './types'

View File

@@ -1,4 +1,4 @@
import { z } from 'zod'
import * as z from 'zod'
const arrayStringSchemaParttern = z.array(z.string())
const arrayNumberSchemaParttern = z.array(z.number())
@@ -7,7 +7,7 @@ const arrayNumberSchemaParttern = z.array(z.number())
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()])
type Literal = z.infer<typeof literalSchema>
type Json = Literal | { [key: string]: Json } | Json[]
const jsonSchema: z.ZodType<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]))
const jsonSchema: z.ZodType<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(z.string(), jsonSchema)]))
const arrayJsonSchema: z.ZodType<Json[]> = z.lazy(() => z.array(jsonSchema))
export const validateJSONSchema = (schema: any, type: string) => {

View File

@@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import * as z from 'zod'
import Button from '@/app/components/base/button'
import { formContext, useAppForm } from '@/app/components/base/form'
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
@@ -22,10 +22,10 @@ import Input from '../components/base/input'
import Loading from '../components/base/loading'
const accountFormSchema = z.object({
email: z
.string()
.min(1, { message: 'error.emailInValid' })
.email('error.emailInValid'),
email: z.email('error.emailInValid')
.min(1, {
error: 'error.emailInValid',
}),
})
const ForgotPasswordForm = () => {

View File

@@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import * as z from 'zod'
import Button from '@/app/components/base/button'
import { formContext, useAppForm } from '@/app/components/base/form'
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
@@ -22,13 +22,15 @@ import { encryptPassword as encodePassword } from '@/utils/encryption'
import Loading from '../components/base/loading'
const accountFormSchema = z.object({
email: z
.string()
.min(1, { message: 'error.emailInValid' })
.email('error.emailInValid'),
name: z.string().min(1, { message: 'error.nameEmpty' }),
email: z.email('error.emailInValid')
.min(1, {
error: 'error.emailInValid',
}),
name: z.string().min(1, {
error: 'error.nameEmpty',
}),
password: z.string().min(8, {
message: 'error.passwordLengthInValid',
error: 'error.passwordLengthInValid',
}).regex(validPassword, 'error.passwordInvalid'),
})
@@ -197,7 +199,7 @@ const InstallForm = () => {
</div>
<div className={cn('mt-1 text-xs text-text-secondary', {
'text-red-400 !text-sm': passwordErrors && passwordErrors.length > 0,
'!text-sm text-red-400': passwordErrors && passwordErrors.length > 0,
})}
>
{t('error.passwordInvalid', { ns: 'login' })}

View File

@@ -4912,11 +4912,6 @@
"count": 7
}
},
"app/install/installForm.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/reset-password/layout.tsx": {
"ts/no-explicit-any": {
"count": 1

View File

@@ -82,6 +82,7 @@
"@remixicon/react": "4.7.0",
"@sentry/react": "8.55.0",
"@svgdotjs/svg.js": "3.2.5",
"@t3-oss/env-nextjs": "0.13.10",
"@tailwindcss/typography": "0.5.19",
"@tanstack/react-form": "1.23.7",
"@tanstack/react-query": "5.90.5",
@@ -159,7 +160,7 @@
"ufo": "1.6.3",
"use-context-selector": "2.0.0",
"uuid": "10.0.0",
"zod": "3.25.76",
"zod": "4.3.6",
"zundo": "2.3.0",
"zustand": "5.0.9"
},

70
web/pnpm-lock.yaml generated
View File

@@ -125,6 +125,9 @@ importers:
'@svgdotjs/svg.js':
specifier: 3.2.5
version: 3.2.5
'@t3-oss/env-nextjs':
specifier: 0.13.10
version: 0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)
'@tailwindcss/typography':
specifier: 0.5.19
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
@@ -357,8 +360,8 @@ importers:
specifier: 10.0.0
version: 10.0.0
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 4.3.6
version: 4.3.6
zundo:
specifier: 2.3.0
version: 2.3.0(zustand@5.0.9(@types/react@19.2.9)(immer@11.1.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))
@@ -2817,6 +2820,40 @@ packages:
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@t3-oss/env-core@0.13.10':
resolution: {integrity: sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==}
peerDependencies:
arktype: ^2.1.0
typescript: '>=5.0.0'
valibot: ^1.0.0-beta.7 || ^1.0.0
zod: ^3.24.0 || ^4.0.0
peerDependenciesMeta:
arktype:
optional: true
typescript:
optional: true
valibot:
optional: true
zod:
optional: true
'@t3-oss/env-nextjs@0.13.10':
resolution: {integrity: sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ==}
peerDependencies:
arktype: ^2.1.0
typescript: '>=5.0.0'
valibot: ^1.0.0-beta.7 || ^1.0.0
zod: ^3.24.0 || ^4.0.0
peerDependenciesMeta:
arktype:
optional: true
typescript:
optional: true
valibot:
optional: true
zod:
optional: true
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
@@ -7538,9 +7575,6 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@@ -8239,7 +8273,7 @@ snapshots:
eslint: 9.39.2(jiti@1.21.7)
ts-pattern: 5.9.0
typescript: 5.9.3
zod: 3.25.76
zod: 4.3.6
transitivePeerDependencies:
- supports-color
@@ -9954,6 +9988,20 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
'@t3-oss/env-core@0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)':
optionalDependencies:
typescript: 5.9.3
valibot: 1.2.0(typescript@5.9.3)
zod: 4.3.6
'@t3-oss/env-nextjs@0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)':
dependencies:
'@t3-oss/env-core': 0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)
optionalDependencies:
typescript: 5.9.3
valibot: 1.2.0(typescript@5.9.3)
zod: 4.3.6
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
postcss-selector-parser: 6.0.10
@@ -11948,8 +11996,8 @@ snapshots:
'@babel/parser': 7.28.6
eslint: 9.39.2(jiti@1.21.7)
hermes-parser: 0.25.1
zod: 3.25.76
zod-validation-error: 4.0.2(zod@3.25.76)
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
@@ -15595,11 +15643,9 @@ snapshots:
zen-observable@0.8.15: {}
zod-validation-error@4.0.2(zod@3.25.76):
zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
zod: 3.25.76
zod@3.25.76: {}
zod: 4.3.6
zod@4.3.6: {}

View File

@@ -1,173 +0,0 @@
import { z, ZodError } from 'zod'
describe('Zod Features', () => {
it('should support string', () => {
const stringSchema = z.string()
const numberLikeStringSchema = z.coerce.string() // 12 would be converted to '12'
const stringSchemaWithError = z.string({
required_error: 'Name is required',
invalid_type_error: 'Invalid name type, expected string',
})
const urlSchema = z.string().url()
const uuidSchema = z.string().uuid()
expect(stringSchema.parse('hello')).toBe('hello')
expect(() => stringSchema.parse(12)).toThrow()
expect(numberLikeStringSchema.parse('12')).toBe('12')
expect(numberLikeStringSchema.parse(12)).toBe('12')
expect(() => stringSchemaWithError.parse(undefined)).toThrow('Name is required')
expect(() => stringSchemaWithError.parse(12)).toThrow('Invalid name type, expected string')
expect(urlSchema.parse('https://dify.ai')).toBe('https://dify.ai')
expect(uuidSchema.parse('123e4567-e89b-12d3-a456-426614174000')).toBe('123e4567-e89b-12d3-a456-426614174000')
})
it('should support enum', () => {
enum JobStatus {
waiting = 'waiting',
processing = 'processing',
completed = 'completed',
}
expect(z.nativeEnum(JobStatus).parse(JobStatus.waiting)).toBe(JobStatus.waiting)
expect(z.nativeEnum(JobStatus).parse('completed')).toBe('completed')
expect(() => z.nativeEnum(JobStatus).parse('invalid')).toThrow()
})
it('should support number', () => {
const numberSchema = z.number()
const numberWithMin = z.number().gt(0) // alias min
const numberWithMinEqual = z.number().gte(0)
const numberWithMax = z.number().lt(100) // alias max
expect(numberSchema.parse(123)).toBe(123)
expect(numberWithMin.parse(50)).toBe(50)
expect(numberWithMinEqual.parse(0)).toBe(0)
expect(() => numberWithMin.parse(-1)).toThrow()
expect(numberWithMax.parse(50)).toBe(50)
expect(() => numberWithMax.parse(101)).toThrow()
})
it('should support boolean', () => {
const booleanSchema = z.boolean()
expect(booleanSchema.parse(true)).toBe(true)
expect(booleanSchema.parse(false)).toBe(false)
expect(() => booleanSchema.parse('true')).toThrow()
})
it('should support date', () => {
const dateSchema = z.date()
expect(dateSchema.parse(new Date('2023-01-01'))).toEqual(new Date('2023-01-01'))
})
it('should support object', () => {
const userSchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
email: z.string().email(),
age: z.number().min(0).max(120).optional(),
})
type User = z.infer<typeof userSchema>
const validUser: User = {
id: 1,
name: 'John',
email: 'john@example.com',
age: 30,
}
expect(userSchema.parse(validUser)).toEqual(validUser)
})
it('should support object optional field', () => {
const userSchema = z.object({
name: z.string(),
optionalField: z.optional(z.string()),
})
type User = z.infer<typeof userSchema>
const user: User = {
name: 'John',
}
const userWithOptionalField: User = {
name: 'John',
optionalField: 'optional',
}
expect(userSchema.safeParse(user).success).toEqual(true)
expect(userSchema.safeParse(userWithOptionalField).success).toEqual(true)
})
it('should support object intersection', () => {
const Person = z.object({
name: z.string(),
})
const Employee = z.object({
role: z.string(),
})
const EmployedPerson = z.intersection(Person, Employee)
const validEmployedPerson = {
name: 'John',
role: 'Developer',
}
expect(EmployedPerson.parse(validEmployedPerson)).toEqual(validEmployedPerson)
})
it('should support record', () => {
const recordSchema = z.record(z.string(), z.number())
const validRecord = {
a: 1,
b: 2,
}
expect(recordSchema.parse(validRecord)).toEqual(validRecord)
})
it('should support array', () => {
const numbersSchema = z.array(z.number())
const stringArraySchema = z.string().array()
expect(numbersSchema.parse([1, 2, 3])).toEqual([1, 2, 3])
expect(stringArraySchema.parse(['a', 'b', 'c'])).toEqual(['a', 'b', 'c'])
})
it('should support promise', async () => {
const promiseSchema = z.promise(z.string())
const validPromise = Promise.resolve('success')
await expect(promiseSchema.parse(validPromise)).resolves.toBe('success')
})
it('should support unions', () => {
const unionSchema = z.union([z.string(), z.number()])
expect(unionSchema.parse('success')).toBe('success')
expect(unionSchema.parse(404)).toBe(404)
})
it('should support functions', () => {
const functionSchema = z.function().args(z.string(), z.number(), z.optional(z.string())).returns(z.number())
const validFunction = (name: string, age: number, _optional?: string): number => {
return age
}
expect(functionSchema.safeParse(validFunction).success).toEqual(true)
})
it('should support undefined, null, any, and void', () => {
const undefinedSchema = z.undefined()
const nullSchema = z.null()
const anySchema = z.any()
expect(undefinedSchema.parse(undefined)).toBeUndefined()
expect(nullSchema.parse(null)).toBeNull()
expect(anySchema.parse('anything')).toBe('anything')
expect(anySchema.parse(3)).toBe(3)
})
it('should safeParse would not throw', () => {
expect(z.string().safeParse('abc').success).toBe(true)
expect(z.string().safeParse(123).success).toBe(false)
expect(z.string().safeParse(123).error).toBeInstanceOf(ZodError)
})
})