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 to Null
  • Not Null evaluates to Null
  • If Null Then takes 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


null
marshals to NULL; 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:

  1. Which of the five states are reachable here, given every assignment on every path that leads to this point?
  2. Does this site's behavior differ across those states?
  3. 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.

Topics:VB6C#

Comments

Subscribe to GAPVelocity AI Modernization Blog

FREE CODE ASSESSMENT TOOL