Extensions
String+Extensions.swift
Every app that takes an email address needs to decide, before it ever hits the network, whether the thing the user typed is even shaped like an email. String+Extensions.swift is where ShipThatApp answers that question. It adds a single, focused isValidEmail() method to Swift's native String type — no library, no regex sprawl in your view models, just a clean call site that reads like English.
It's small on purpose. The win isn't the regex; it's that validation lives in one testable place and the rest of the app calls into it instead of re-implementing email checks (badly) in three different screens.
Location: ShipThatApp/Utils/Extensions/String+Extensions.swift
What it provides
isValidEmail()
A Bool-returning method on String that matches the receiver against a standard email pattern using NSPredicate. This is the exact implementation that ships:
extension String {
/// Check if the string is a valid email.
func isValidEmail() -> Bool {
let emailFormat = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailFormat)
return emailPredicate.evaluate(with: self)
}
}
The pattern accepts the common local-part characters (._%+- plus alphanumerics), requires an @, a domain, and a top-level domain between 2 and 64 characters. NSPredicate's MATCHES operator anchors the whole string, so partial matches like foo@bar are rejected — there's no trailing TLD.
How to use it
Because it's an extension on String, it's available on any string with zero imports beyond Foundation. Call it directly at the point you need a decision:
if userInput.isValidEmail() {
// proceed
} else {
// surface an error
}
In the real app, SignInViewModel is the primary consumer. It gates every email-based auth path — magic link, password sign-in, and registration — behind isValidEmail(), setting a user-facing errorText when the check fails:
func isFormValid(email: String, password: String) -> Bool {
errorText = nil
guard email.isValidEmail() else {
errorText = "Provide correct email address"
return false
}
guard password.count >= 7 else {
errorText = "Provide password that is at least 7 characters long"
return false
}
return true
}
The magic-link path uses it directly too, short-circuiting the network call when the address is malformed:
func sendMagicLink(email: String) async throws {
if email.isValidEmail() {
try await AuthManager.shared.sendMagicLink(email: email)
} else {
errorText = "Provide correct email address"
}
}
That's the whole point of the extension: the view model expresses intent (email.isValidEmail()) and never has to know about regex or NSPredicate.
Where this fits
This helper is the first line of defense in the Authentication Flow. For the view model that calls it on every sign-in attempt, see SignInViewModel; for the screens that wire it to the UI, see SignInView.
Customize / extend it
This file is intentionally a single method so it's an obvious home for the other string utilities every shipping app eventually needs. A few that drop straight in alongside isValidEmail():
extension String {
/// Trimmed, lowercased copy — handy before storing or comparing emails.
var normalized: String {
trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
/// True when the string has visible, non-whitespace content.
var hasContent: Bool {
!trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Lightweight password-strength gate to pair with `isValidEmail()`.
func isStrongPassword(minLength: Int = 7) -> Bool {
count >= minLength
}
}
Validation is necessary, not sufficient
A passing isValidEmail() only means the string is shaped like an email — it does not mean the address exists or is deliverable. Treat it as a fast, client-side gate to catch typos before a network round-trip. The authoritative check is the magic-link or sign-up response from Supabase. Never rely on the regex alone for anything security-sensitive.
Testing it
Because the logic is isolated on a value type with no dependencies, it's trivial to cover with unit tests:
import Testing
@testable import ShipThatApp
@Test func emailValidation() {
#expect("test@example.com".isValidEmail())
#expect("a.b+tag@sub.domain.io".isValidEmail())
#expect(!"test_at_example.com".isValidEmail())
#expect(!"missing@tld".isValidEmail())
#expect(!"".isValidEmail())
}
This is exactly the kind of pure, side-effect-free function you want pinned by tests before you customize the pattern — change the regex, run the suite, ship with confidence.