Skip to content

Commit 4c89c6a

Browse files
committed
feat: add postal code validation rule
1 parent 9775b7d commit 4c89c6a

File tree

4 files changed

+940
-0
lines changed

4 files changed

+940
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ struct RegistrationView: View {
316316
| `ComparisonValidationRule` | Validates that input against a comparison constraint | `ComparisonValidationRule(greaterThan: 0, error: "Must be greater than 0")`
317317
| `IBANValidationRule` | Validates that a string is a valid IBAN (International Bank Account Number) | `IBANValidationRule(error: "Invalid IBAN")`
318318
| `IPAddressValidationRule` | Validates that a string is a valid IPv4 or IPv6 address | `IPAddressValidationRule(version: .v4, error: ValidationError("Invalid IPv4"))`
319+
| `PostalCodeValidationRule` | Validates postal/ZIP codes for different countries | `PostalCodeValidationRule(country: .uk, error: "Invalid post code")`
319320

320321
## Custom Validators
321322

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//
2+
// Validator
3+
// Copyright © 2025 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
// MARK: - PostalCodeValidationRule
9+
10+
/// A validation rule used to verify postal/ZIP codes for different countries.
11+
///
12+
/// `PostalCodeValidationRule` validates user input based on predefined
13+
/// country-specific postal code formats using regular expressions.
14+
///
15+
/// # Example:
16+
/// ```swift
17+
/// let rule = PostalCodeValidationRule(country: .uk, error: "Invalid postal code")
18+
/// rule.validate(input: "SW1A 1AA") // true
19+
/// rule.validate(input: "123456") // false
20+
/// ```
21+
///
22+
public struct PostalCodeValidationRule: IValidationRule {
23+
// MARK: Types
24+
25+
// swiftlint:disable identifier_name
26+
/// Supported countries with unique postal code formats.
27+
public enum Country {
28+
case us
29+
case uk
30+
case ca
31+
case de
32+
case fr
33+
case it
34+
case es
35+
case nl
36+
case be
37+
case ch
38+
case at
39+
case au
40+
case nz
41+
case jp
42+
case cn
43+
case `in`
44+
case br
45+
case mx
46+
case ar
47+
case za
48+
case ru
49+
case pl
50+
case se
51+
case no
52+
case dk
53+
case fi
54+
case pt
55+
case gr
56+
case cz
57+
case ie
58+
case sg
59+
case kr
60+
case il
61+
case tr
62+
}
63+
64+
// swiftlint:enable identifier_name
65+
66+
// MARK: Properties
67+
68+
/// The country format to validate against.
69+
public let country: Country
70+
71+
/// Validation error returned when validation fails.
72+
public let error: IValidationError
73+
74+
// MARK: Initialization
75+
76+
/// Creates a new postal code validation rule.
77+
///
78+
/// - Parameters:
79+
/// - country: The country whose postal code format should be used.
80+
/// - error: Error object returned when validation fails.
81+
public init(country: Country, error: IValidationError) {
82+
self.country = country
83+
self.error = error
84+
}
85+
86+
// MARK: ValidationRule
87+
88+
public func validate(input: String) -> Bool {
89+
let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines)
90+
91+
if trimmedInput.isEmpty {
92+
return false
93+
}
94+
95+
if input != trimmedInput {
96+
return false
97+
}
98+
99+
let pattern = getPattern(for: country)
100+
101+
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
102+
return false
103+
}
104+
105+
let range = NSRange(location: 0, length: input.utf16.count)
106+
return regex.firstMatch(in: input, options: [], range: range) != nil
107+
}
108+
109+
// MARK: Private Methods
110+
111+
// swiftlint:disable function_body_length cyclomatic_complexity
112+
private func getPattern(for country: Country) -> String {
113+
switch country {
114+
case .us:
115+
#"^\d{5}(-\d{4})?$"#
116+
117+
case .ca:
118+
#"^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] \d[ABCEGHJ-NPRSTV-Z]\d$"#
119+
120+
case .mx:
121+
#"^\d{5}$"#
122+
123+
case .br:
124+
#"^\d{5}-\d{3}$"#
125+
126+
case .ar:
127+
#"^([A-Z]\d{4}([A-Z]{3})?|\d{4})$"#
128+
129+
case .uk:
130+
#"^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$"#
131+
132+
case .de:
133+
#"^\d{5}$"#
134+
135+
case .fr:
136+
#"^\d{5}$"#
137+
138+
case .it:
139+
#"^\d{5}$"#
140+
141+
case .es:
142+
#"^\d{5}$"#
143+
144+
case .nl:
145+
#"^\d{4} [A-Z]{2}$"#
146+
147+
case .be:
148+
#"^\d{4}$"#
149+
150+
case .ch:
151+
#"^\d{4}$"#
152+
153+
case .at:
154+
#"^\d{4}$"#
155+
156+
case .pt:
157+
#"^\d{4}-\d{3}$"#
158+
159+
case .gr:
160+
#"^\d{3} \d{2}$"#
161+
162+
case .ie:
163+
#"^[A-Z]\d{2} [A-Z0-9]{4}$"#
164+
165+
case .se:
166+
#"^\d{3} \d{2}$"#
167+
168+
case .no:
169+
#"^\d{4}$"#
170+
171+
case .dk:
172+
#"^\d{4}$"#
173+
174+
case .fi:
175+
#"^\d{5}$"#
176+
177+
case .ru:
178+
#"^\d{6}$"#
179+
180+
case .pl:
181+
#"^\d{2}-\d{3}$"#
182+
183+
case .cz:
184+
#"^\d{3} \d{2}$"#
185+
186+
case .jp:
187+
#"^\d{3}-\d{4}$"#
188+
189+
case .cn:
190+
#"^\d{6}$"#
191+
192+
case .in:
193+
#"^\d{6}$"#
194+
195+
case .sg:
196+
#"^\d{6}$"#
197+
198+
case .kr:
199+
#"^\d{5}$"#
200+
201+
case .il:
202+
#"^\d{7}$"#
203+
204+
case .tr:
205+
#"^\d{5}$"#
206+
207+
case .au:
208+
#"^\d{4}$"#
209+
210+
case .nz:
211+
#"^\d{4}$"#
212+
213+
case .za:
214+
#"^\d{4}$"#
215+
}
216+
}
217+
// swiftlint:enable function_body_length cyclomatic_complexity
218+
}

Sources/ValidatorCore/Validator.docc/Overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ ValidatorCore contains all core validation rules, utilities, and mechanisms for
3939
- ``ComparisonValidationRule``
4040
- ``IBANValidationRule``
4141
- ``IPAddressValidationRule``
42+
- ``PostalCodeValidationRule``
4243

4344
### Articles
4445

0 commit comments

Comments
 (0)