Overview
In our Go architecture, we usego-playground/validator for data validation, which is similar to Zod in TypeScript. Both provide declarative, type-safe validation with excellent error messages.
📊 Comparison: Zod vs Go Validator
| Feature | Zod (TypeScript) | go-playground/validator (Go) |
|---|---|---|
| Syntax | Fluent API | Struct tags |
| Type Safety | ✅ Yes | ✅ Yes (compile-time) |
| Validation | Runtime | Runtime |
| Error Messages | Customizable | Customizable |
| Ecosystem | npm package | Go module |
| Performance | Good | Excellent (compiled) |
🎯 How It Works in Our Architecture
1. Define Request DTOs with Validation Tags
// TypeScript (Zod)
const RegisterSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
password: z.string().min(8),
roles: z.array(z.string()).min(1)
})
// Go (Validator Tags)
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required"`
Password string `json:"password" validate:"required,min=8"`
Roles []string `json:"roles" validate:"required,min=1"`
}
2. Validation Flow in HTTP Handler
func (h *AuthHandler) Register(c echo.Context) error {
var req RegisterRequest
// Step 1: Bind JSON to struct
if err := c.Bind(&req); err != nil {
return err // 400 Bad Request (malformed JSON)
}
// Step 2: Validate struct
if err := c.Validate(&req); err != nil {
return err // 400 Bad Request (validation failed)
}
// Step 3: Use validated data
user, err := h.userService.Create(ctx, ports.CreateUserParams{
Email: req.Email,
Name: req.Name,
Password: req.Password,
Roles: req.Roles,
}, "system")
return c.JSON(http.StatusCreated, user)
}
🏗️ Architecture Layers
📝 Common Validation Tags
Basic Validations
type UserDTO struct {
// Required field
Email string `validate:"required"`
// Email format
Email string `validate:"required,email"`
// String length
Name string `validate:"required,min=2,max=100"`
// Number range
Age int `validate:"required,min=18,max=120"`
// Array/Slice
Roles []string `validate:"required,min=1,max=5"`
// Enum (one of)
Status string `validate:"required,oneof=active inactive pending"`
// URL
Website string `validate:"omitempty,url"`
// UUID
ID string `validate:"required,uuid"`
// Custom regex
Phone string `validate:"required,e164"` // E.164 phone format
}
Advanced Validations
type ProductDTO struct {
// Conditional validation
Price float64 `validate:"required_if=IsPaid true"`
// Cross-field validation
Password string `validate:"required,min=8"`
ConfirmPassword string `validate:"required,eqfield=Password"`
// Nested struct validation
Address AddressDTO `validate:"required"`
// Array of structs
Items []ItemDTO `validate:"required,dive,required"`
}
type AddressDTO struct {
Street string `validate:"required"`
City string `validate:"required"`
ZipCode string `validate:"required,numeric,len=5"`
}
🎨 Custom Validation
1. Register Custom Validator
// pkg/validator/validator.go
func New() *CustomValidator {
v := validator.New()
// Register custom validation
v.RegisterValidation("strong_password", validateStrongPassword)
return &CustomValidator{validator: v}
}
func validateStrongPassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
// Must contain: uppercase, lowercase, number, special char
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
hasSpecial := regexp.MustCompile(`[!@#$%^&*]`).MatchString(password)
return hasUpper && hasLower && hasNumber && hasSpecial
}
2. Use Custom Validator
type RegisterRequest struct {
Password string `validate:"required,min=8,strong_password"`
}
🚨 Error Handling
Current Implementation
// pkg/validator/validator.go
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return nil
}
Enhanced Error Messages (Recommended)
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
// Parse validation errors
validationErrors := err.(validator.ValidationErrors)
// Build user-friendly error messages
errors := make(map[string]string)
for _, e := range validationErrors {
field := e.Field()
switch e.Tag() {
case "required":
errors[field] = fmt.Sprintf("%s is required", field)
case "email":
errors[field] = fmt.Sprintf("%s must be a valid email", field)
case "min":
errors[field] = fmt.Sprintf("%s must be at least %s characters", field, e.Param())
default:
errors[field] = fmt.Sprintf("%s is invalid", field)
}
}
return echo.NewHTTPError(http.StatusBadRequest, errors)
}
return nil
}
{
"message": {
"Email": "Email must be a valid email",
"Password": "Password must be at least 8 characters"
}
}
🔄 Comparison with Your TypeScript Code
TypeScript (Hono + Zod)
// Define schema
const registerSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
password: z.string().min(8),
roles: z.array(z.string()).min(1),
});
// Validate in route
app.post("/register", async (c) => {
const body = await c.req.json();
const validated = registerSchema.parse(body); // Throws if invalid
const user = await userService.create(validated);
return c.json(user, 201);
});
Go (Echo + Validator)
// Define DTO
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required"`
Password string `json:"password" validate:"required,min=8"`
Roles []string `json:"roles" validate:"required,min=1"`
}
// Validate in handler
func (h *AuthHandler) Register(c echo.Context) error {
var req RegisterRequest
if err := c.Bind(&req); err != nil {
return err
}
if err := c.Validate(&req); err != nil {
return err // Returns 400 if invalid
}
user, err := h.userService.Create(ctx, ports.CreateUserParams{
Email: req.Email,
Name: req.Name,
Password: req.Password,
Roles: req.Roles,
}, "system")
return c.JSON(http.StatusCreated, user)
}
🎯 Best Practices
1. Separate DTOs from Domain Models
// ❌ Bad: Using domain model directly
func (h *Handler) Create(c echo.Context) error {
var user domain.User // Domain model
c.Bind(&user)
c.Validate(&user)
}
// ✅ Good: Use separate DTO
func (h *Handler) Create(c echo.Context) error {
var req CreateUserRequest // DTO
c.Bind(&req)
c.Validate(&req)
// Map DTO to domain
user := domain.NewUser(req.Email, req.Name, ...)
}
2. Layer-Specific Validation
// HTTP Layer: Format validation
type CreateProductRequest struct {
Name string `validate:"required,min=3"`
Price float64 `validate:"required,gt=0"`
}
// Service Layer: Business logic validation
func (s *ProductService) Create(params CreateProductParams) error {
// Check if product name already exists
existing, _ := s.repo.FindByName(params.Name)
if existing != nil {
return ErrProductNameExists
}
// Check if price is within allowed range
if params.Price > 10000 {
return ErrPriceTooHigh
}
}
3. Validation Groups (Optional)
type User struct {
Email string `validate:"required,email"`
Password string `validate:"required,min=8"` // Only for create
}
// For update, password is optional
type UpdateUserRequest struct {
Email string `validate:"omitempty,email"`
Password string `validate:"omitempty,min=8"`
}
📚 Available Validation Tags
| Tag | Description | Example |
|---|---|---|
required | Field must be present | validate:"required" |
email | Valid email format | validate:"email" |
min | Minimum length/value | validate:"min=8" |
max | Maximum length/value | validate:"max=100" |
len | Exact length | validate:"len=10" |
gt | Greater than | validate:"gt=0" |
gte | Greater than or equal | validate:"gte=18" |
lt | Less than | validate:"lt=100" |
lte | Less than or equal | validate:"lte=120" |
oneof | One of values | validate:"oneof=red blue green" |
url | Valid URL | validate:"url" |
uuid | Valid UUID | validate:"uuid" |
numeric | Numeric string | validate:"numeric" |
alpha | Alphabetic only | validate:"alpha" |
alphanum | Alphanumeric only | validate:"alphanum" |
e164 | E.164 phone format | validate:"e164" |
omitempty | Skip if empty | validate:"omitempty,email" |