VB6 Has Five Kinds of Nothing. C# Has One
by DeeDee Walsh, on Aug 29, 2026, 7:59:58 PM
Here's a line of VB6 that runs in production somewhere in your building right now:
vb
If Not (rs!Status = "ACTIVE") Then Call LogInactive(rs!AccountID) End If
And here's the C# translation:
csharp
if (!(status == "ACTIVE")) { LogInactive(accountId); }
For every record where Status is populated, these two do the same thing. For every record where Status is database-null, they do the opposite. The VB6 version doesn't log. The C# version does.
Nobody notices this at compile time. Nobody notices it in a smoke test against clean data. It surfaces eleven months later as an exception report with forty thousand rows in it, and by then the legacy system has been decommissioned and there's nothing to diff against.
The cause is that VB6 has five distinct ways to say "there is no value here," and C# has one.
The five
Dim v As Variant ' Empty v = Null ' Null Set obj = Nothing ' Nothing s = vbNullString ' null string pointer Sub F(Optional ByVal x As Variant) ' Missing, when omitted
|
State |
VarType |
TypeName |
What it means |
|
Empty |
0 (vbEmpty) |
"Empty" |
A Variant that's never been assigned |
|
Null |
1 (vbNull) |
"Null" |
Data that's known to be absent — the database sense |
|
Nothing |
9 (vbObject) |
"Nothing" |
An object reference pointing at no object |
|
vbNullString |
8 (vbString) |
"String" |
A string pointer whose value is 0 |
|
Missing |
10 (vbError) |
"Error" |
An optional Variant argument the caller omitted |
The five test functions don't overlap. IsEmpty is true only for Empty. IsNull is true only for Null. IsMissing is true only for an omitted Variant optional. Is Nothing applies only to object references, and using = on an object reference instead of Is invokes the default property, which raises error 91 when the reference is Nothing.
Every one of these collapses to null in an unconsidered translation. Four of the five collapses are wrong.
Null's the one that'll hurt you
Null in VB6 isn't a marker value. It's a third truth state, and it propagates.
Null + 5 ' Null "abc" & Null ' "abc" (concatenation ignores it) "abc" + Null ' Null (addition does not) Len(Null) ' Null Null = Null ' Null (not True)
That last line is the one that catches people. If v = Null Then is never true, for any value of v, including Null. This is why IsNull exists.
Now walk the opening example again. rs!Status is Null:
rs!Status = "ACTIVE"evaluates toNullNot Nullevaluates toNullIf Null Thentakes the false branch
So both If rs!Status = "ACTIVE" and If Not (rs!Status = "ACTIVE") take the else branch. Null-status records fall out of both sides of the conditional. That's SQL's three-valued logic, sitting inside a desktop application, and thirty years of VB6 business rules have been written on top of it, often by developers who never articulated the rule but tested until the output looked right.
In C#, null == "ACTIVE" is false, and !false is true. The negation inverts. Positive tests survive the translation; negated tests silently flip.
The correct construction
C# can reproduce this exactly, and the mechanism is already in the language. Lifted operators on bool? implement three-valued logic:
bool? a = null; a & false // false a & true // null a | true // true !a // null
And because if requires a bool, you're forced to state what you want to happen with the third state:
bool? isActive = VbCompare.Eq(status, "ACTIVE"); // null when status is VB6-Null if (!isActive == true) { LogInactive(accountId); }
!isActive is null, null == true is false, and the branch doesn't execute. That matches VB6. It's also self-documenting in a way the original wasn't, because == true is now visible at every site where three-valued logic is live.
Empty equals zero and empty string at the same time
Dim Discount As Variant ' never assigned If Discount = 0 Then ' True If Discount = "" Then ' True
Empty coerces to 0 in numeric context and "" in string context, and it's equal to both. There's no C# value with this property. object v = null is equal to neither.
This matters most in accumulator and flag patterns, where a Variant is declared, conditionally assigned inside a branch, and then tested. If the branch didn't run, VB6 gives you a value that passes both the numeric and the string test. Pick null and both tests fail. Pick 0 and the string test fails. Pick "" and the numeric test fails. The only correct answer depends on which test the code actually performs downstream, which means you have to read the downstream code.
vbNullString is not ""
vbNullString = "" ' True StrPtr(vbNullString) ' 0 StrPtr("") ' a valid pointer
Comparison says they're the same. The memory says otherwise, and the memory is what gets marshaled across a Declare.
Private Declare Function FindWindow Lib "user32" Alias "FindWindowA" _ (ByVal lpClassName As String, ByVal lpWindowName As String) As Long h = FindWindow(vbNullString, "Invoice Entry") ' matches any window class h = FindWindow("", "Invoice Entry") ' matches a class named "" — returns 0
Dozens of Win32 entry points read NULL as "unspecified, use the default" and a pointer-to-empty-string as "the empty value, literally." CreateFile, GetPrivateProfileString, RegQueryValueEx, most of the shell APIs.
This is the one case where C# preserves the distinction for free, provided nobody normalizes it away:
[DllImport("user32.dll", CharSet = CharSet.Ansi, SetLastError = true)] static extern IntPtr FindWindow(string lpClassName, string lpWindowName); FindWindow(null, "Invoice Entry"); // NULL FindWindow("", "Invoice Entry"); // pointer to a zero-length string
marshals to
nullNULL; string.Empty marshals to a pointer. The failure mode's a translation pass that "cleans up" vbNullString into string.Empty or "" because they compare equal in VB6. They do compare equal. They do not behave equal at the ABI.
Missing isn't the same as passed-as-Null
Public Function PostEntry(ByVal Amount As Currency, _ Optional ByVal EffectiveDate As Variant) As Boolean If IsMissing(EffectiveDate) Then EffectiveDate = Date ' ... End Function
Two call sites:
PostEntry(1500) ' omitted → IsMissing True → defaults to today PostEntry(1500, rs!PostDate) ' Null → IsMissing False → Null flows downstream
The second call deliberately doesn't default. A Null posting date propagates through the rest of the function and lands in the ledger as Null, which is what the original developer intended: an unset posting date isn't today's date, it's an unset posting date.
Collapse both to DateTime? effectiveDate = null and the second call now defaults to today. You haven't thrown an exception. You've written wrong dates into a ledger.
One more trap: IsMissing only works for Variant optionals with no default. Declare it as Optional ByVal EffectiveDate As Date and the parameter receives Date's default value instead, and IsMissing returns False forever. Mixed-style optional parameter lists inside the same module are common, and they translate differently.
The engineering problem
The goal isn't to port VB6's type system into C#. Carrying five states through a modern codebase produces something nobody will maintain, and the customer didn't commission a VB6 emulator.
The goal is to prove which of the five states can reach each site, and collapse everything that can't.
That's three questions per Variant use:
- Which of the five states are reachable here, given every assignment on every path that leads to this point?
- Does this site's behavior differ across those states?
- If it does, what's the target representation —
null,DBNull.Value,bool?with lifted operators, an explicit branch, or a restructure that removes the ambiguity entirely?
Most sites answer "one state reachable" and collapse cleanly to idiomatic C#. A minority answer "two or more," and those are the ones that need the three-valued construction above. The engineering value's in the separation, because a codebase where every Variant is defensively wrapped is as unmaintainable as one where none of them are.
Note what question 1 requires. Reachability of Null at a given line depends on recordset field nullability, on module-level Variant state, on form-level state that outlives the procedure, on optional arguments at every call site, and on On Error Resume Next blocks that let an unassigned Variant survive to the next statement. None of that's visible in the fifty lines around the code. It's a whole-program property.
Which is the short answer to why pasting a procedure into a chat window and asking for the C# produces something that compiles, reads well, passes review, and is wrong on the records that matter. The model's answering a question about a snippet. The question is about a system.
Find these in your own codebase
Before anyone quotes you a modernization number, run these. The counts tell you how much three-valued logic is live in your application:
|
Search |
What it finds |
|
= Null and <> Null |
Comparisons that are always false including existing bugs, before you migrate anything |
|
IsNull(, IsEmpty(, IsMissing( |
Sites where the distinction is known to be critical |
|
vbNullString |
Every place the pointer matters, usually near a Declare |
|
Optional with no As clause |
Variant optionals, where IsMissing is live |
|
Not ( near a field reference |
The negation-inversion pattern from the top of this post |
|
Declare |
Your P/Invoke surface, where "" and null diverge |
A high IsNull count is good news. It means the original team knew. A high = Null count means they did not, and some of your current behavior is accidental.
FAQ
Is Null the same as Nothing? No. Null is a Variant value representing absent data. Nothing is an object reference pointing at no object. They have different types, different test functions, and different comparison operators — IsNull versus Is Nothing.
Is vbNullString the same as ""? They compare as equal in VB6. They're different pointers, and Win32 APIs called through Declare can distinguish them.
Doesn't VB.NET already solve this? VB.NET removed Null and Empty and folded everything into a single Nothing, which also serves as default(T). That collapse is the source of a large share of the data-handling defects that came out of Upgrade Wizard-era conversions. Moving to VB.NET doesn't answer the question; it answers it wrong by default and quietly.
Can I just map everything to null and add guards later? You can, and the guards will be added at the sites where someone eventually notices. The sites nobody notices are the negated conditionals, which fail silently and only on null data.
What does DBNull have to do with this? System.DBNull exists in .NET for exactly this reason: ADO.NET needed a value distinct from null to represent database null. DBNull.Value != null is true. It's a legitimate target for VB6 Null where the value stays in a data-access path, though bool? and explicit branching usually read better in business logic.
If you're still battling your VB6 code, sign up for our webinar where we'll show you how to use agents to modernize your VB6 code.


