Introduction
This post ties together a couple of things we've talked about before: algebraic data types made illegal combinations of fields unrepresentable. Smart constructors take that one level deeper, and make illegal values of an otherwise-valid shape unrepresentable too. An EmailAddress is structurally a String, but not every string is a valid email address. So how do you stop an invalid EmailAddress from ever being built?
Python: no enforced visibility, just convention
Python does have some kind of public/protected/private visibility: a leading underscore (_value) signals fields or methods that are meant to be protected, aka internal, while a leading double underscore (__value) triggers name mangling, thus rewriting the attribute to _ClassName__value under the hood, preventing external code to access them.
However there is, once again, a problem: using leading underscore for protected fields/methods is just a convention and, if a developer decides to access them, the runtime won't stop them to do so. But that's not really a problem, right? the __init__ constructor is private, and therefore it gets mangled, right?
Well... no, it does not. Mangling applies to plain names only, and dunder methods like __init__ are explicitly exempt from that. The bottom line is: there is no way to hide a dangerous constructor; all you can do is mark the factory with classmethod and hope everyone reaches for it.
And that, again, is a hint: nothing stops a caller going straight to the real constructor and skipping validation.
class EmailAddress:
def __init__(self, value: str):
self.value = value # no validation here, and nothing stops
# calling this constructor directly.
@classmethod
def parse(cls, raw: str) -> "EmailAddress":
if "@" not in raw:
raise ValueError("invalid email")
return cls(raw)
EmailAddress.parse("not-an-email") # correctly throws an error
EmailAddress("not-an-email") # ...but this works fine tooTypeScript: the same problem
A static factory method, plus a private constructor, is the idiomatic TypeScript pattern. But "private" on a constructor is enforced by the type checker only within TypeScript-checked code. Nothing prevents a different, looser-typed part of the codebase (or a // @ts-ignore) from calling new directly, and it's a discipline every class needs to opt into individually.
class EmailAddress {
private constructor(readonly value: string) {}
static parse(raw: string): EmailAddress {
if (!raw.includes("@")) throw new Error("invalid email");
return new EmailAddress(raw);
}
}
EmailAddress.parse("not-an-email"); // correctly throws
// new EmailAddress("not-an-email"); // TS correctly flags this as an error,
// but it's a compile-time-only checkJava: a real solution, just not the default
Java's static factory idiom has the same shape as TypeScript's: a private constructor and a public static method that validates first. Java's version is the real thing rather than a type-checker illusion: the compiler enforces a private constructor, so there is no // @ts-ignore waiting on the other side of it. However, safe as it is, the catch here is that nothing in the language points you toward writing it this way. You apply it by hand, class by class, exactly as in Python and TypeScript, and it is the first thing to go under deadline pressure.
class EmailAddress {
private final String value;
private EmailAddress(String value) {
this.value = value;
}
static EmailAddress parse(String raw) {
if (!raw.contains("@")) throw new IllegalArgumentException("invalid email");
return new EmailAddress(raw);
}
}
EmailAddress.parse("not-an-email"); // correctly throws
// new EmailAddress("not-an-email"); // throws a compile error: "EmailAddress(String)
// has private access in EmailAddress"Records offer a shortcut for the validation half of this, at least: a compact canonical constructor runs before the fields are even assigned, so you can reject bad input before an invalid EmailAddress ever exists, no separate factory method required.
record EmailAddress(String value) {
EmailAddress {
if (!value.contains("@")) throw new IllegalArgumentException("invalid email");
}
}Scala: companion object with a smart constructor
A Scala companion object is an object sharing its name with a class in the same file and scope, with privileged access to that class's internals, and it is where this pattern lives natively. It is the expected home for everything that belongs to the class rather than to an instance: JSON and protobuf codecs, JDBC mappers, and so called "smart constructors" with the special name apply:
case class EmailAddress private (val value: String)
object EmailAddress:
def apply(raw: String): Either[String, EmailAddress] =
// here "new" isn't strictly required; we just use it to show that
// this companion object has privileged access to the private constructor
if raw.contains("@") then Right(new EmailAddress(raw))
else Left(s"invalid email: $raw")
EmailAddress("not-an-email") // Left("invalid email: not-an-email")
EmailAddress("ada@sfyi.com") // Right(EmailAddress(ada@sfyi.com))
new EmailAddress("ada@sfyi.com") // compiler error:
// "constructor EmailAddress cannot be accessed"
// notice, however, that we were able to call it
// from within the companion object ;)Did you catch what happened in that snippet?
Normally, if you define case class Dummy(v: Int) and write val d = Dummy(1), then d has type Dummy. Here, because we:
- marked the
case classconstructor asprivate, and ... - ... defined a smart constructor
def apply(raw: String): Either[String, EmailAddress]in its companion object ...
... writing EmailAddress("not-an-email") did not call the default constructor. It called our smart constructor. The proof is in the return type: not a plain EmailAddress but, instead, an Either[String, EmailAddress] 🎉
And if you force the default constructor with new, the compiler stops you, because there is no public constructor to reach: only the companion object is allowed to see it.
So as a library author you put your validation and safety checks in the smart constructor, and every caller gets them without noticing, and without learning a separate syntax for building instances of your class.
Notice, again, the return type: Either[String, EmailAddress], not a thrown exception. That ties back directly to what we covered on Option and for-comprehensions. Validation failures are represented in the type, compose with for-comprehensions, and the compiler forces every caller to handle it. It cannot be silently forgotten, the way an uncaught exception might.
Once you have this, you use it everywhere
The pattern costs a private constructor and a companion apply, which is close to nothing, so idiomatic Scala wraps far more of its domain in validated types than equivalent Java, Python or TypeScript does in practice, even though all three could do the same. A PositiveInt, a NonEmptyString, a ValidatedEmail: each one, once written, makes a whole class of "did anyone check this value?" bugs impossible to write.
Closing the loop on validation an agent might skip
This is a theme we keep coming back to: an AI agent that generates a function accepting a plain String for an email address has no way to know, just from the type, that some kind of validation is expected. It might validate, it might not, and the type signature gives it (and you, reviewing the diff) no signal either way. A function that accepts an EmailAddress instead makes the requirement visible in the signature itself: the only way a value of that type exists anywhere in the program, is if it already passed through the companion's apply. The agent doesn't need to remember to validate. By the time it has a value of the right type in hand, that work is already done.
It is a small pattern, and it shows the shape of the whole series. Scala does not win these comparisons with one killer feature. It wins because a handful of small things (sealed hierarchies, Option, immutable collections, a private constructor plus a companion object) all push the same way. Let the type system carry as much of the correctness work as it can, by default, so that neither a human reviewer nor an agent has to hold every invariant in their head at once.
