Short answer: No — and here's a single real example that kills the argument.
"Types which can be nullable are semantically (and compile-time) equivalent to optional types. We already have
nullas 'absence is explicit.' Unless you want different values of absence, isn'tstring?the same asOption<string>?"
Dictionary<string, object> row = db.ExecuteQuery("SELECT * FROM Users WHERE Id = 2")[0];
string name = (string)row["Name"]; // Compiler: ✅ fine, object is non-null
// Runtime: 💥 NullReferenceExceptionThe type is non-nullable. The value is null. The compiler is happy. The app crashes.
This isn't contrived — it's every ORM, every DataReader, every JSON deserializer, every dictionary lookup in every real application. The moment data crosses a boundary (database, network, file, reflection, interop), the compiler's nullability tracking is erased.
Option<T> doesn't have this failure mode. You physically cannot access the inner value without pattern-matching on Some/None. The absence is encoded in the value, not in a compiler annotation that the runtime ignores.
| Property | string? (NRT) |
Option<string> |
|---|---|---|
| Where enforced | Compile-time annotation only | Runtime value — the type is the check |
| Reflection/deserialization bypass | Yes — trivially | No — you get None, not a secret null |
| Composable | No — manual if (x != null) chains |
Yes — .Map(), .Bind(), .Match() |
| Proves absence was handled | No — warnings are suppressible, not errors | Yes — won't compile without handling both arms |
| Works across trust boundaries | No — external data ignores your annotations | Yes — the boundary returns Option<T> |
The argument isn't about more kinds of absence. It's about where the absence is enforced:
- NRT: The compiler believes the annotation. The runtime doesn't. That's a lie the type system tells itself.
- Option<T>: The value is the proof. There is no gap between what the compiler knows and what the runtime does.
The statement "runtime data semantics cannot always be fully proven at compile time" means exactly this: the compiler can annotate intent, but it cannot enforce contracts on data it has never seen (SQL results, JSON payloads, reflection-populated DTOs). Option<T> closes that gap by making the proof travel with the value.
A fair critique of an earlier version of the Option<T> showcase code:
// ⚠️ Original example (now updated in docs) — shown here for context
var email = (await fdb.GetByIdAsync<UserDto>("Users", 99))
.Map(u => u.Email)
.Bind(e => string.IsNullOrEmpty(e) ? Option<string>.None : Option<string>.Some(e))
.IfNone("no-email");"What if
"Users"should be"User"? What about"no-email"— is that a prefix convention? String-based keys, magic strings, ternaries for the logic you're promoting... This is just runtime errors with extra steps."
This critique was valid — and we've since updated all documentation examples. But the deeper point is worth addressing: the complaint targets the example, not the concept. Let's separate the two.
The complaint is about stringly-typed API design — and that's a real problem regardless of whether you use Option<T> or not. The classic version has the exact same magic strings:
// Classic — same magic strings, same problems, PLUS silent nulls
var rows = db.ExecuteQuery("SELECT * FROM Users WHERE Id = 99"); // "Users" typo? Same risk.
if (rows.Count > 0)
{
var email = (string)rows[0]["Email"]; // silent null, no compiler help
if (!string.IsNullOrEmpty(email))
SendEmail(email);
else
SendEmail("no-email"); // same magic fallback
}The magic strings exist because the database API is string-based. That's not Option<T>'s fault — it's the reality of talking to a schema-less query layer.
The real answer to "I'd expect expressions so they could be compile-time checked" is: yes, you should, and Option<T> composes perfectly with that:
// Strong-typed table reference — no magic strings
var email = (await fdb.GetByIdAsync<UserDto>(Tables.Users, userId))
.Map(u => u.Email)
.Bind(Option.FromNullOrEmpty) // built-in, no ternary
.IfNone(Defaults.NoEmail); // named constant, not a magic stringEvery magic string is now a compile-time symbol. The Option<T> chain is unchanged — because Option<T> was never the source of the magic strings.
The original comparison wasn't "this API has perfect ergonomics." It was:
| Failure mode | Classic (NRT) | Option<T> |
|---|---|---|
Table name typo ("Users" vs "User") |
Silent empty result or exception | Silent empty result → None (you must handle it) |
| Row missing | IndexOutOfRangeException 💥 |
None |
| Column null | NullReferenceException 💥 |
None |
| Forgot to check | Compiles fine, crashes at runtime | Won't compile — Option<T> forces a decision |
The table name typo is equally bad in both worlds. But in the classic version you get three additional unforced crash vectors that Option<T> eliminates.
The critique correctly identifies that the showcase example was sloppy with magic strings. It does not invalidate the core claim: Option<T> forces you to handle absence at every step, while NRT lets nulls slip through silently. Better examples should use strongly-typed table references and named constants — and Option<T> works just as well with those.
"Aren't nullable types just optional types?"
No.
string?is a compile-time promise the runtime is free to break — and does, every time data comes from a database, API, or deserializer.Option<T>is a runtime-enforced value that won't let you touch the data without proving you handled absence. The gap between annotation and enforcement is where real apps crash.Full write-up → Functional Null Safety in SharpCoreDB
See also: FUNCTIONAL_NULL_SAFETY.md for tested examples, benchmarks, and the full Option<T> / Fin<T> API in SharpCoreDB v1.7.0.