Chain Order: The #1 Gotcha in CtroEnv
Chain Order: The #1 Gotcha in CtroEnv
Every validator starts with type-specific methods like .url() or .min(), then loses them after calling .secret() or .optional(). Get the order wrong and you get a TypeScript error that's easy to fix once you understand why.
The Problem
This looks reasonable but fails:
string().secret().url()
// ~~~~~~~
// Property 'url' does not exist on type 'Validator<string> & ChainableMethods<string>'
The fix is to swap the order:
string().url().secret() // ✅
Why It Happens
Each chainable method returns a generic Validator & ChainableMethods wrapper. The type-specific methods (.url(), .email(), .min(), .int(), .port()) only exist on StringValidator and NumberValidator — not on the generic wrapper.
The call chain:
string() → StringValidator (has .url, .email, .port, .min, .max, .regex)
.url() → StringValidator (still has all methods)
.secret() → Validator & ChainableMethods (lost .url, .email, etc.)
.min(1) ❌ doesn't exist
Correct Order
Type-specific refinements first, then chainable methods:
// ✅ String
string().url().min(1).secret()
string().email().optional()
string().port().describe("Redis port")
string().hostname().regex(/^myapp-/).secret()
// ✅ Number
number().int().positive().min(1).max(100).default(50)
number().port().optional()
number().min(0).describe("Timeout in ms")
// ✅ Chainable methods can be in any order among themselves
string().url().secret().describe("API URL").optional()
// ^^^^^^^^
// Wait — .optional() after .secret() is fine because both return
// the same generic wrapper. But .optional() at the end means
// the value can be undefined. That's intentional.
What About .describe() and .validate()?
.describe() and .validate() are also chainable methods — they return the generic wrapper. But they don't conflict because you'd never need type-specific methods after describing:
string().url().describe("API URL") // ✅ fine — no .url() needed after describe
Debugging Chain Order Errors
TypeScript error:
Property 'url' does not exist on type 'Validator<string> & ChainableMethods<string>'
This means you called a chainable method (.secret(), .optional(), .describe(), or .validate()) before a type-specific method. Move the type-specific call before the chainable one.
Why This Design?
CtroEnv keeps the core package at zero dependencies and 4 KB gzipped. A builder-pattern chain that preserved all type methods would require complex generic machinery or runtime type tracking. The current design is explicit and predictable: type-specific refinements first, modifiers second.
Quick Reference
// ✅ Correct
string().min(1).max(255).url().secret()
number().int().positive().default(42)
pick(["a", "b"]).optional()
boolean().default(false)
// ❌ Wrong
string().secret().url()
number().optional().int()
pick(["a", "b"]).secret() // works (pick has no type refinements to lose)
pick() has no type-specific refinements, so chain order doesn't matter for it. Same with boolean(), semver(), ip(), uuid(), and guid().