Skip to content

feat(zod): generate runtime validation from Forge invariants #11

Description

@dev-queiroz

Summary

Implement Zod schema generation from Forge invariants using the structured expression parser. Extends Zod generator to produce runtime validation code that enforces field constraints and invariant rules.

Motivation

The DB Schema IR foundation and invariant parser create an opportunity for robust runtime validation. Currently, generated Zod schemas validate types but not business constraints. Example:

contract Produto {
  valor: decimal
  invariant valor > 0
  invariant valor < 1000000
}

Should generate:

export const ProdutoSchema = z.object({
  valor: z.number()
    .refine(v => v > 0, "valor must be > 0")
    .refine(v => v < 1000000, "valor must be < 1000000")
});

This enables:

  • Compile-time type safety
  • Runtime constraint validation
  • Consistent validation across all backends (CLI, API, services)
  • Clear error messages for consumers

Scope

In Scope

Out of Scope

  • Custom validation functions
  • Async validators (.refine(async ...))
  • Complex invariant expressions (AND/OR)
  • Custom error message i18n

Technical Design

Type Mapping

Operator → Zod Validation
==       → .refine(v => v === value)
!=       → .refine(v => v !== value)
<        → .refine(v => v < value)
>        → .refine(v => v > value)
<=       → .refine(v => v <= value)
>=       → .refine(v => v >= value)

Implementation

  1. New Module: packages/generators/src/invariants.ts

    function generateZodConstraint(invariant: InvariantExpression): string
    function applyInvariants(field: FieldModel, invariants: InvariantModel[]): string
  2. Integration Point: Modify generateZodContract() in index.ts

    // For each field, find matching invariants
    const fieldInvariants = contract.invariants.filter(inv => 
      inv.parsed?.left === field.name
    );
    // Apply constraints to field schema
  3. Error Messages:

    `${field.name} must satisfy: ${invariant.expression}`

Edge Cases Handled

  • Optional fields: Constraint only applied if value provided
  • Multiple invariants on same field: Chained with .refine().refine()
  • String values: Quote in error message
  • Numeric comparisons: Handle int/float/decimal
  • Field references: Resolve field name to value path

Acceptance Criteria

  • Generated Zod schema includes .refine() for each invariant
  • Schema compiles and runs without errors
  • Validation passes for data satisfying invariants
  • Validation fails for data violating invariants
  • Error message contains invariant expression
  • Optional fields: constraint not applied when undefined
  • All 8 primitive types supported
  • No TypeScript compilation errors in generated code
  • Backward compatible: contracts without invariants unchanged
  • Performance: <10ms to generate schema for 100-field contract

Testing Strategy

Unit Tests

// tests/zod-constraints.test.mjs

test('generate refine for > operator', () => {
  const invariant = parseInvariant('valor > 0');
  const zod = generateZodConstraint(invariant);
  expect(zod).toContain('refine(v => v > 0)');
})

test('validation passes for valid value', () => {
  const schema = generateSchema(productContract);
  const valid = { valor: 100 };
  expect(schema.parse(valid)).toEqual(valid);
})

test('validation fails for invalid value', () => {
  const schema = generateSchema(productContract);
  const invalid = { valor: -10 };
  expect(() => schema.parse(invalid)).toThrow();
})

test('optional field: constraint skipped for undefined', () => {
  const schema = generateSchema(userWithOptionalAge);
  const noAge = { nome: 'John' };
  expect(schema.parse(noAge)).toEqual(noAge);
})

test('multiple invariants chained', () => {
  const contract = {
    fields: [{ name: 'valor', type: 'decimal', optional: false }],
    invariants: [
      { left: 'valor', operator: '>', right: 0 },
      { left: 'valor', operator: '<', right: 1000000 }
    ]
  };
  const schema = generateSchema(contract);
  expect(schema.parse({ valor: 500 })).toBeDefined();
  expect(() => schema.parse({ valor: -10 })).toThrow();
  expect(() => schema.parse({ valor: 2000000 })).toThrow();
})

Integration Tests

  • Generate full Zod schema with invariants
  • Import generated schema into test file
  • Execute validation test suite
  • Verify TypeScript compilation

Performance Tests

  • <10ms for typical contract (20 fields, 30 invariants)
  • <100ms for large contract (100 fields, 200 invariants)

Files to Create/Modify

New Files:

  • packages/generators/src/invariants.ts (120 lines)
  • tests/zod-constraints.test.mjs (200 lines)

Modified Files:

  • packages/generators/src/index.ts (lines ~60, add invariant handling)

Non-Goals

  • SQL constraint generation (Feature 1.1.3 - database layer)
  • OpenAPI documentation with constraints (Feature 1.1.4)
  • NestJS validation pipes (Feature 1.1.3)
  • UI/form validation

Future Extensions

  • Async validation with external services
  • Cross-field validation: validate(obj) => validateCrossFields(obj)
  • Custom error formatters
  • i18n error messages
  • Localization support

Dependencies

Blocks:

  • Feature 1.1.3: NestJS Validation Pipes (depends on this)
  • Feature 1.1.4: OpenAPI Documentation (depends on this)

Depends On:

Implementation Notes

Type System Consideration

When reading invariants, be defensive:

const parsed = invariant.parsed || parseInvariant(invariant.expression);

This handles the transition period where some invariants may not be parsed yet.

Zod Validation Pattern

// Pattern for numeric comparison
z.number().refine(
  v => v > 0,
  { message: "valor must be > 0" }
)

// Pattern for string comparison  
z.string().refine(
  v => v.length > 0,
  { message: "field must be non-empty" }
)

Testing with Zod InferType

// Verify generated type matches expectation
type GeneratedType = z.infer<typeof GeneratedSchema>;
type Expected = { valor: number };
// TypeScript should not error if they match

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions