Skip to content

Latest commit

 

History

History
123 lines (83 loc) · 6.59 KB

File metadata and controls

123 lines (83 loc) · 6.59 KB

Are Nullable Types Semantically Equivalent to Optional Types?

Short answer: No — and here's a single real example that kills the argument.


The Claim

"Types which can be nullable are semantically (and compile-time) equivalent to optional types. We already have null as 'absence is explicit.' Unless you want different values of absence, isn't string? the same as Option<string>?"

The Killer Counter-Example

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:  💥 NullReferenceException

The 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.

The Precise Distinction

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>

Why "Different Values of Absence" Is a Red Herring

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.

"But Your Example Is Full of Magic Strings"

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.

What the critique actually proves

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.

What a properly typed version looks like

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 string

Every 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.

What the critique misses

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 honest summary

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.


TL;DR

"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.