Operator Overloading and Equality

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example is a complete console program that starts with Option Strict On, was built with warnings treated as errors and was run in the .NET 10 SDK container (Linux, invariant culture); the output shown is real output. The projects use the root namespace ShelfDocs, which is why a few messages and type names below carry that prefix. The examples that are meant to fail say so and show the compiler messages that the SDK printed. Where an example compares with C# it was built as a real C# project that references the Visual Basic one.

A type decides for itself what +, =, < or a conversion means, and how it behaves in a HashSet, a Dictionary or a sort. Visual Basic gives you two separate mechanisms for that, and most of the confusion about equality comes from mixing them up:

  • Operator procedures (Operator =, Operator +, Widening Operator CType and so on) change what an expression such as a = b means in source code. They are resolved by the compiler from the declared types of the operands.

  • The equality and ordering contract (Object.Equals, GetHashCode, IEquatable(Of T), IComparable(Of T) and comparers) is what the .NET class library calls at run time: List(Of T).Contains, Dictionary(Of K, V), Distinct, Sort and SortedSet(Of T) never look at your operators.

A well-behaved type defines both, with the same meaning. The running example is the shelf library catalogue: the Isbn structure, a few catalogue entries and a Money structure for late fees. The Operator statement and operator overloading itself have existed since Visual Basic 2005 (VB 8.0, .NET Framework 2.0), together with the Widening and Narrowing conversion operators, IsTrue/IsFalse and the lifting of operators to nullable value types. What changed around it since is the class library (IEquatable(Of T), HashCode, generic math), and one of those changes Visual Basic cannot use, as shown near the end.

Choosing what to implement

You want Implement Because

a = b and a <> b in source

Operator = and Operator <>, as a pair

the compiler resolves them from operand types; the library never calls them

List.Contains, Dictionary, HashSet, Distinct, IndexOf

IEquatable(Of T), Equals(Object) and GetHashCode

EqualityComparer(Of T).Default uses IEquatable(Of T), otherwise Equals(Object)

Sort, SortedSet, OrderBy, Max, Min, BinarySearch

IComparable(Of T) (and optionally IComparable)

Comparer(Of T).Default uses it

a < b, a >= b in source

the four relational operators, in pairs, usually written with CompareTo

same reason as =

a + b, -a, a * 2D

the matching Operator procedures

arithmetic has no library hook

Dim s As String = isbn

a Widening Operator CType

conversions have their own operator kind

If a AndAlso b, If flag Then for your type

Operator IsTrue/IsFalse and (for AndAlso/OrElse) And/Or

short-circuit evaluation is built from them

A different ordering or "equal ignoring case" without touching the type

IComparer(Of T) and IEqualityComparer(Of T) objects

one type can have many orderings and many equalities

Operator procedures

An operator procedure is a Public Shared procedure named by an operator symbol. The general shape is:

[<attributes>] Public [Overloads] Shared [Shadows] [Widening | Narrowing]
    Operator operatorsymbol(operand1 [, operand2]) [As type]
    ...
    Return value
End Operator

The rules, from the Language Reference and checked against the compiler:

  • The declaration context is a class or a structure. An operator inside a Module is rejected (BC33018), and an interface cannot declare one (BC30603: a statement cannot appear within an interface body).

  • The modifiers are exactly Public and Shared: omit Shared and you get BC33012, use Private or Friend and you get BC33011.

  • Operands are always passed ByVal; ByRef, Optional and ParamArray are rejected (BC30651, BC33010).

  • At least one operand must be of the containing type (for a conversion, the operand or the return type). Nullable forms of the containing type count. Otherwise: BC33021.

  • A unary operator has one operand, a binary operator two (BC33014, BC33015). Unary + and - and binary + and - are separate declarations.

  • The return type is free: Money / Money can return a Decimal, and = can return something that is not a Boolean (although a Boolean is what every caller expects).

  • The operands may have different types, so Money * Decimal and Decimal * Money are two operators, and a type that should multiply commutatively declares both.

  • Overloads is optional on the type that declares the operator, but it is needed to overload (rather than shadow) the operator of a base class, as shown further down.

  • Nothing is inherited in the usual sense: the operator is a static method, and the compiler picks it from the declared types of the operands, never from the run-time types.

A first example: money

Option Strict On

Imports System.Globalization

Public Structure Money
    Implements IEquatable(Of Money), IComparable(Of Money)

    Private ReadOnly _cents As Long

    Public Sub New(cents As Long)
        _cents = cents
    End Sub

    Public Shared ReadOnly Property Zero As Money = New Money(0)

    Public Shared Function FromEuros(euros As Decimal) As Money
        Return New Money(CLng(Math.Round(euros * 100D, MidpointRounding.AwayFromZero)))
    End Function

    Public ReadOnly Property Cents As Long
        Get
            Return _cents
        End Get
    End Property

    ' Arithmetic: binary and unary forms are separate operator procedures.
    Public Shared Operator +(left As Money, right As Money) As Money
        Return New Money(left._cents + right._cents)
    End Operator

    Public Shared Operator -(left As Money, right As Money) As Money
        Return New Money(left._cents - right._cents)
    End Operator

    Public Shared Operator -(value As Money) As Money
        Return New Money(-value._cents)
    End Operator

    Public Shared Operator +(value As Money) As Money
        Return value
    End Operator

    ' Mixed operand types: both orders are needed for commutativity.
    Public Shared Operator *(value As Money, factor As Decimal) As Money
        Return New Money(CLng(Math.Round(value._cents * factor, MidpointRounding.AwayFromZero)))
    End Operator

    Public Shared Operator *(factor As Decimal, value As Money) As Money
        Return value * factor
    End Operator

    Public Shared Operator /(value As Money, divisor As Decimal) As Money
        Return New Money(CLng(Math.Round(value._cents / divisor, MidpointRounding.AwayFromZero)))
    End Operator

    ' The result type need not be the operand type: Money / Money is a ratio.
    Public Shared Operator /(left As Money, right As Money) As Decimal
        Return CDec(left._cents) / CDec(right._cents)
    End Operator

    ' Comparison operators must be declared in pairs.
    Public Shared Operator =(left As Money, right As Money) As Boolean
        Return left._cents = right._cents
    End Operator

    Public Shared Operator <>(left As Money, right As Money) As Boolean
        Return left._cents <> right._cents
    End Operator

    Public Shared Operator <(left As Money, right As Money) As Boolean
        Return left._cents < right._cents
    End Operator

    Public Shared Operator >(left As Money, right As Money) As Boolean
        Return left._cents > right._cents
    End Operator

    Public Shared Operator <=(left As Money, right As Money) As Boolean
        Return left._cents <= right._cents
    End Operator

    Public Shared Operator >=(left As Money, right As Money) As Boolean
        Return left._cents >= right._cents
    End Operator

    Public Overloads Function Equals(other As Money) As Boolean Implements IEquatable(Of Money).Equals
        Return _cents = other._cents
    End Function

    Public Overrides Function Equals(obj As Object) As Boolean
        Return TypeOf obj Is Money AndAlso Equals(DirectCast(obj, Money))
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return _cents.GetHashCode()
    End Function

    Public Function CompareTo(other As Money) As Integer Implements IComparable(Of Money).CompareTo
        Return _cents.CompareTo(other._cents)
    End Function

    Public Overrides Function ToString() As String
        Return (CDec(_cents) / 100D).ToString("0.00", CultureInfo.InvariantCulture) & " EUR"
    End Function
End Structure

Module Program
    Sub Main()
        Dim perDay = Money.FromEuros(0.25D)
        Dim fee = perDay * 12
        Dim waived = -Money.FromEuros(1.5D)

        Dim total As Money = Money.Zero
        total += fee
        total += waived
        Console.WriteLine("fee    = " & fee.ToString())
        Console.WriteLine("waived = " & waived.ToString())
        Console.WriteLine("total  = " & total.ToString())
        Console.WriteLine("3 * perDay = " & (3 * perDay).ToString())
        Console.WriteLine("fee / 5    = " & (fee / 5).ToString())
        Console.WriteLine("fee / perDay = " & (fee / perDay).ToString(CultureInfo.InvariantCulture))

        Console.WriteLine("fee > total: " & (fee > total))
        Console.WriteLine("fee = perDay * 12: " & (fee = perDay * 12))
        Console.WriteLine("fee <> total: " & (fee <> total))

        Dim fees As New List(Of Money) From {fee, waived, perDay, Money.Zero}
        fees.Sort()
        Console.WriteLine("sorted: " & String.Join(", ", fees))
    End Sub
End Module
fee    = 3.00 EUR
waived = -1.50 EUR
total  = 1.50 EUR
3 * perDay = 0.75 EUR
fee / 5    = 0.60 EUR
fee / perDay = 12
fee > total: True
fee = perDay * 12: True
fee <> total: True
sorted: -1.50 EUR, 0.00 EUR, 0.25 EUR, 3.00 EUR

Points worth noticing in this example:

  • total += fee is a compound assignment: the compiler rewrites it as total = total + fee, so it works for any type that has the binary operator. The compound forms that exist are +=, -=, *=, /=, \=, ^=, &=, <<= and >>=. There is no And=, Or=, Xor= or Mod= (the compiler rejects them with parse errors such as BC30201); write x = x Or y.

  • 3 * perDay and perDay * 12 both compile because both orders were declared; the integer literal widens to Decimal for the parameter. Without the second declaration the first form would not compile (BC30311, "Value of type 'Integer' cannot be converted to 'Money'").

  • fee / perDay returns a Decimal: the result type of an operator is whatever you declare.

  • fees.Sort() does not use <; it uses IComparable(Of Money). The relational operators and the interface were written separately here and both delegate to the same field comparison. Keep them consistent.

  • Money is stored as a whole number of cents. Arithmetic on money in a Double is a classic source of rounding bugs; see Numbers and Math for Decimal and rounding modes.

Which operators can be overloaded

The Language Reference lists the operators that can appear in an Operator statement. The compiler enforces the list: anything else, such as AndAlso, is rejected with BC33002.

Kind Visual Basic operator .NET method name (op_) C# counterpart

Unary

+ and -

op_UnaryPlus, op_UnaryNegation

unary +, -

Unary

Not

op_OnesComplement

~

Unary

IsTrue, IsFalse

op_True, op_False

true, false

Binary arithmetic

+, -, *, /

op_Addition, op_Subtraction, op_Multiply, op_Division

+, -, *, /

Binary arithmetic

\

op_IntegerDivision

none

Binary arithmetic

Mod

op_Modulus

%

Binary arithmetic

^

op_Exponent

none

Concatenation, pattern

&, Like

op_Concatenate, op_Like

none

Logical and bitwise

And, Or, Xor

op_BitwiseAnd, op_BitwiseOr, op_ExclusiveOr

&, |, ^

Shifts

<<, >>

op_LeftShift, op_RightShift

<<, >> (C# 11 adds >>>)

Comparison (matched pairs)

= and <>, < and >, <= and >=

op_Equality, op_Inequality, op_LessThan, op_GreaterThan, op_LessThanOrEqual, op_GreaterThanOrEqual

==, !=, <, >, <=, >=

Conversion

Widening CType, Narrowing CType

op_Implicit, op_Explicit

implicit, explicit

Not overloadable, and why: AndAlso and OrElse are derived from And/IsFalse and Or/IsTrue; Is, IsNot, TypeOf ... Is, If(...), GetType, AddressOf and Await are not operations on your type; compound assignment is derived from the binary operator. The right operand of ^ may have any type you declare, but the shift operators must take an Integer (or Integer?) count (BC33041).

The exact op_ names matter in two situations: when you call Visual Basic operators from another language, and when you use reflection. This program declares one of everything and prints what the compiler emitted:

Option Strict On

Imports System.Reflection

' One of every overloadable operator; the program prints what the compiler emitted.
Public Structure Bits
    Public ReadOnly Value As Integer

    Public Sub New(value As Integer)
        Me.Value = value
    End Sub

    Public Shared Operator +(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value + b.Value)
    End Operator

    Public Shared Operator -(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value - b.Value)
    End Operator

    Public Shared Operator -(a As Bits) As Bits
        Return New Bits(-a.Value)
    End Operator

    Public Shared Operator +(a As Bits) As Bits
        Return a
    End Operator

    Public Shared Operator *(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value * b.Value)
    End Operator

    Public Shared Operator /(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value \ b.Value)
    End Operator

    Public Shared Operator \(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value \ b.Value)
    End Operator

    Public Shared Operator Mod(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value Mod b.Value)
    End Operator

    Public Shared Operator ^(a As Bits, b As Integer) As Bits
        Return New Bits(CInt(Math.Pow(a.Value, b)))
    End Operator

    Public Shared Operator &(a As Bits, b As Bits) As Bits
        Return New Bits(CInt(a.Value.ToString() & b.Value.ToString()))
    End Operator

    Public Shared Operator Like(a As Bits, pattern As String) As Boolean
        Return a.Value.ToString() Like pattern
    End Operator

    Public Shared Operator And(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value And b.Value)
    End Operator

    Public Shared Operator Or(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value Or b.Value)
    End Operator

    Public Shared Operator Xor(a As Bits, b As Bits) As Bits
        Return New Bits(a.Value Xor b.Value)
    End Operator

    Public Shared Operator Not(a As Bits) As Bits
        Return New Bits(Not a.Value)
    End Operator

    Public Shared Operator <<(a As Bits, count As Integer) As Bits
        Return New Bits(a.Value << count)
    End Operator

    Public Shared Operator >>(a As Bits, count As Integer) As Bits
        Return New Bits(a.Value >> count)
    End Operator

    Public Shared Operator IsTrue(a As Bits) As Boolean
        Return a.Value <> 0
    End Operator

    Public Shared Operator IsFalse(a As Bits) As Boolean
        Return a.Value = 0
    End Operator

    Public Shared Operator =(a As Bits, b As Bits) As Boolean
        Return a.Value = b.Value
    End Operator

    Public Shared Operator <>(a As Bits, b As Bits) As Boolean
        Return a.Value <> b.Value
    End Operator

    Public Shared Operator <(a As Bits, b As Bits) As Boolean
        Return a.Value < b.Value
    End Operator

    Public Shared Operator >(a As Bits, b As Bits) As Boolean
        Return a.Value > b.Value
    End Operator

    Public Shared Operator <=(a As Bits, b As Bits) As Boolean
        Return a.Value <= b.Value
    End Operator

    Public Shared Operator >=(a As Bits, b As Bits) As Boolean
        Return a.Value >= b.Value
    End Operator

    Public Shared Widening Operator CType(value As Integer) As Bits
        Return New Bits(value)
    End Operator

    Public Shared Narrowing Operator CType(value As Bits) As Integer
        Return value.Value
    End Operator

    Public Overrides Function Equals(obj As Object) As Boolean
        Return TypeOf obj Is Bits AndAlso DirectCast(obj, Bits).Value = Value
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return Value
    End Function
End Structure

Module Program
    Sub Main()
        Dim names = GetType(Bits).GetMethods(BindingFlags.Public Or BindingFlags.Static Or BindingFlags.DeclaredOnly) _
            .Where(Function(m) m.IsSpecialName AndAlso m.Name.StartsWith("op_", StringComparison.Ordinal)) _
            .Select(Function(m) m.Name & "(" & String.Join(",", m.GetParameters().Select(Function(p) p.ParameterType.Name)) & ")->" & m.ReturnType.Name) _
            .OrderBy(Function(n) n, StringComparer.Ordinal)
        For Each n In names
            Console.WriteLine(n)
        Next
    End Sub
End Module
op_Addition(Bits,Bits)->Bits
op_BitwiseAnd(Bits,Bits)->Bits
op_BitwiseOr(Bits,Bits)->Bits
op_Concatenate(Bits,Bits)->Bits
op_Division(Bits,Bits)->Bits
op_Equality(Bits,Bits)->Boolean
op_ExclusiveOr(Bits,Bits)->Bits
op_Explicit(Bits)->Int32
op_Exponent(Bits,Int32)->Bits
op_False(Bits)->Boolean
op_GreaterThan(Bits,Bits)->Boolean
op_GreaterThanOrEqual(Bits,Bits)->Boolean
op_Implicit(Int32)->Bits
op_Inequality(Bits,Bits)->Boolean
op_IntegerDivision(Bits,Bits)->Bits
op_LeftShift(Bits,Int32)->Bits
op_LessThan(Bits,Bits)->Boolean
op_LessThanOrEqual(Bits,Bits)->Boolean
op_Like(Bits,String)->Boolean
op_Modulus(Bits,Bits)->Bits
op_Multiply(Bits,Bits)->Bits
op_OnesComplement(Bits)->Bits
op_RightShift(Bits,Int32)->Bits
op_Subtraction(Bits,Bits)->Bits
op_True(Bits)->Boolean
op_UnaryNegation(Bits)->Bits
op_UnaryPlus(Bits)->Bits

A widening conversion becomes op_Implicit and a narrowing one op_Explicit, which is what lets C# and Visual Basic use each other’s conversions. A C# project that references this one can use most of the operators with C# syntax. The operators that C# has no syntax for are ordinary static methods to C#, and can be called by name:

using ShelfDocs;

var a = new Bits(6);
var b = new Bits(3);
Console.WriteLine("a + b   = " + (a + b).Value);
Console.WriteLine("a - b   = " + (a - b).Value);
Console.WriteLine("a * b   = " + (a * b).Value);
Console.WriteLine("a / b   = " + (a / b).Value);
Console.WriteLine("a % b   = " + (a % b).Value);   // Visual Basic Mod
Console.WriteLine("a & b   = " + (a & b).Value);   // Visual Basic And
Console.WriteLine("a | b   = " + (a | b).Value);   // Visual Basic Or
Console.WriteLine("a ^ b   = " + (a ^ b).Value);   // Visual Basic Xor
Console.WriteLine("~a      = " + (~a).Value);      // Visual Basic Not
Console.WriteLine("a << 1  = " + (a << 1).Value);
Console.WriteLine("a == b  = " + (a == b));
Console.WriteLine("a < b   = " + (a < b));
int i = (int)a;          // Visual Basic Narrowing CType
Bits c = 5;              // Visual Basic Widening CType
Console.WriteLine("i, c    = " + i + ", " + c.Value);
if (a) Console.WriteLine("a is true (IsTrue)");
Console.WriteLine("op_Exponent = " + Bits.op_Exponent(a, 2).Value);   // Visual Basic ^
Console.WriteLine("op_IntegerDivision = " + Bits.op_IntegerDivision(a, b).Value);   // Visual Basic \
a + b   = 9
a - b   = 3
a * b   = 18
a / b   = 2
a % b   = 0
a & b   = 2
a | b   = 7
a ^ b   = 5
~a      = -7
a << 1  = 12
a == b  = False
a < b   = False
i, c    = 6, 5
a is true (IsTrue)
op_Exponent = 36
op_IntegerDivision = 2

Bits.op_Exponent(a, 2) works only because C# recognises no ^ exponent operator; the same trick for op_Addition would be an error. For the C# side of the topic see Equality and Operator Overloading in C#.

The error messages, collected

Every rule above has a compile-time error. This file breaks them one by one (each declaration is wrong in the way its comment says), and the compiler reports them all:

Option Strict On

Public Structure Meters
    Public ReadOnly Value As Double

    Public Sub New(value As Double)
        Me.Value = value
    End Sub

    Public Shared Operator <(a As Meters, b As Meters) As Boolean    ' no matching '>'
        Return a.Value < b.Value
    End Operator

    Public Shared Operator +(a As Integer, b As Integer) As Integer  ' no parameter of type Meters
        Return a + b
    End Operator

    Public Shared Operator -(ByRef a As Meters, b As Meters) As Meters  ' ByRef parameter
        Return New Meters(a.Value - b.Value)
    End Operator

    Public Shared Operator *(a As Meters, Optional b As Meters = Nothing) As Meters  ' Optional parameter
        Return a
    End Operator

    Public Operator /(a As Meters, b As Meters) As Meters   ' not Shared
        Return a
    End Operator

    Public Shared Operator Mod(a As Meters, b As Meters, c As Meters) As Meters  ' three operands
        Return a
    End Operator

    Public Shared Operator Not(a As Meters, b As Meters) As Meters  ' Not is unary
        Return a
    End Operator

    Public Shared Operator CType(value As Double) As Meters  ' neither Widening nor Narrowing
        Return New Meters(value)
    End Operator

    Public Shared Widening Operator CType(value As Meters) As Meters  ' converts to itself
        Return value
    End Operator

    Public Shared Widening Operator CType(value As Meters) As Object  ' to a base type
        Return value
    End Operator

    Public Shared Operator <<(a As Meters, count As Long) As Meters    ' shift count must be Integer
        Return a
    End Operator
End Structure

Public Class Tag
End Class

Public Class Derived
    Inherits Tag

    Public Shared Widening Operator CType(value As Derived) As Tag   ' to a base class
        Return value
    End Operator
End Class

Module Program
    Sub Main()
        Dim a As New Meters(1)
        Dim text As String = "x"
        Console.WriteLine(text & a)         ' no widening to String declared
        Dim m As Meters = 3.5               ' no conversion declared
        Console.WriteLine(a = a)            ' '=' not defined
        Dim o As Object = a
        Console.WriteLine(o = o)            ' Object operands under Option Strict On
    End Sub
End Module
Program.vb(10,28): error BC33033: Matching '>' operator is required for 'Public Shared Operator <(a As Meters, b As Meters) As Boolean'.
Program.vb(14,28): error BC33021: At least one parameter of this binary operator must be of the containing type 'Meters'.
Program.vb(18,30): error BC30651: operator parameters cannot be declared 'ByRef'.
Program.vb(22,43): error BC33010: 'operator' parameters cannot be declared 'Optional'.
Program.vb(26,21): error BC33012: Operators must be declared 'Shared'.
Program.vb(30,28): error BC33015: Operator 'Mod' must have two parameters.
Program.vb(34,28): error BC33014: Operator 'Not' must have one parameter.
Program.vb(38,28): error BC33017: Conversion operators must be declared either 'Widening' or 'Narrowing'.
Program.vb(42,37): error BC33024: Conversion operators cannot convert from a type to the same type.
Program.vb(46,37): error BC33028: Conversion operators cannot convert to Object.
Program.vb(50,28): error BC33041: Operator '<<' must have a second parameter of type 'Integer' or 'Integer?'.
Program.vb(61,37): error BC33026: Conversion operators cannot convert from a type to its base type.

Two of the errors stop the compiler before it gets that far, so they are shown separately: an operator in a module and a declaration of AndAlso.

Option Strict On

Public Module Helpers
    Public Operator +(a As Integer, b As Integer) As Integer   ' operator in a Module
        Return a + b
    End Operator
End Module

Public Structure Meters
    Public ReadOnly Value As Double

    Public Shared Operator AndAlso(a As Meters, b As Meters) As Meters  ' not overloadable
        Return a
    End Operator
End Structure

Module Program
    Sub Main()
    End Sub
End Module
Program.vb(4,5): error BC33018: Operators cannot be declared in modules.
Program.vb(12,28): error BC33002: Operator is not overloadable. Operator declaration must be one of:  +, -, *, \, /, ^, &, Like, Mod, And, Or, Xor, Not, <<, >>, =, <>, <, <=, >, >=, CType, IsTrue, IsFalse.

The messages at the use of operators are different, and they are the ones you meet first. The next file declares no operators at all and uses =, <, Is, &, a conversion and a generic +:

Option Strict On

Public Structure Meters
    Public ReadOnly Value As Double

    Public Sub New(value As Double)
        Me.Value = value
    End Sub
End Structure

Public Class Tag
    Public ReadOnly Name As String

    Public Sub New(name As String)
        Me.Name = name
    End Sub
End Class

Module Program
    Function Total(Of T)(a As T, b As T) As T
        Return a + b
    End Function

    Sub Main()
        Dim a As New Meters(1)
        Dim b As New Meters(2)
        Console.WriteLine(a = b)                       ' structure without Operator =
        Console.WriteLine(a < b)                       ' structure without Operator <
        Console.WriteLine(a Is b)                      ' Is on a value type
        Console.WriteLine("x" & a)                     ' & needs strings or a defined operator
        Dim m As Meters = 3.5                          ' no conversion declared
        Dim t1 As New Tag("a")
        Dim t2 As New Tag("a")
        Console.WriteLine(t1 = t2)                     ' class without Operator =
        Dim pair1 = (1, "x")
        Dim pair2 = (1, "x")
        Console.WriteLine(pair1 = pair2)               ' tuples have no = operator in VB
        Dim o1 As Object = t1
        Dim o2 As Object = t2
        Console.WriteLine(o1 = o2)                     ' Object operands under Option Strict On
    End Sub
End Module
Program.vb(21,16): error BC30452: Operator '+' is not defined for types 'T' and 'T'.
Program.vb(27,27): error BC30452: Operator '=' is not defined for types 'Meters' and 'Meters'.
Program.vb(28,27): error BC30452: Operator '<' is not defined for types 'Meters' and 'Meters'.
Program.vb(29,27): error BC30020: 'Is' operator does not accept operands of type 'Meters'. Operands must be reference or nullable types.
Program.vb(29,32): error BC30020: 'Is' operator does not accept operands of type 'Meters'. Operands must be reference or nullable types.
Program.vb(30,27): error BC30452: Operator '&' is not defined for types 'String' and 'Meters'.
Program.vb(31,27): error BC30311: Value of type 'Double' cannot be converted to 'Meters'.
Program.vb(34,27): error BC30452: Operator '=' is not defined for types 'Tag' and 'Tag'.
Program.vb(37,27): error BC30452: Operator '=' is not defined for types '(Integer, String)' and '(Integer, String)'.
Program.vb(40,27): error BC32013: Option Strict On disallows operands of type Object for operator '='. Use the 'Is' operator to test for object identity.
Program.vb(40,32): error BC32013: Option Strict On disallows operands of type Object for operator '='. Use the 'Is' operator to test for object identity.

Reading the list from the top:

  • A structure has no = unless you define it (BC30452), even though ValueType.Equals exists. A class has Is and nothing else; t1 = t2 on two Tag objects is an error, not a reference comparison.

  • Is accepts only reference types and nullable value types (BC30020). To compare two structures use = (once defined) or Equals.

  • "x" & a is BC30452 even when Meters has a widening conversion to String: the & operator does not consider user-defined conversions, so write a.ToString() or CStr(a).

  • A literal 3.5 does not convert to Meters (BC30311) until a conversion operator is declared (see below).

  • Two tuples cannot be compared with =: Visual Basic 17.13 has no tuple equality operator (use Equals).

  • Under Option Strict On the = operator is rejected for Object operands (BC32013). Use Is to test identity.

  • A generic method cannot use + on T (BC30452); there is more on this under generic math.

Further diagnostics that are not worth an example, with the message the SDK printed:

Code Message (shortened) Cause

BC33011

Operators must be declared 'Public'.

Private or Friend operator

BC33033

Matching 'IsFalse' operator is required for 'Public Shared Operator IsTrue(…​)'

IsTrue without IsFalse, or = without <>, < without >

BC40003

operator 'op_Equality' shadows an overloadable member declared in the base class 'CatalogItem'. …​ must be declared 'Overloads'.

derived class declares = or <> without Overloads (a warning, an error with warnings as errors)

BC31409

function 'Equals' must be declared 'Overloads' because another 'Equals' is declared 'Overloads' or 'Overrides'.

Equals(other As T) next to Overrides Equals(obj As Object)

BC40005

function 'Equals' shadows an overridable method in the base class 'ValueType'. …​ must be declared 'Overrides'.

the same mistake as a warning

BC30311

Value of type 'Odd' cannot be converted to 'Integer'.

AndAlso on a type whose And takes mismatched operands (it is reported at the use, not at the declaration)

BC37315

Structure 'Meters' cannot implement interface 'IAdditionOperators(Of Meters, Meters, Meters)' because it contains shared abstract or virtual …​

implementing a static-abstract interface (see generic math)

Operators are chosen at compile time

Operator procedures are static methods, so they are never virtual. A derived class that declares its own = has a second operator, not an override. Which one runs depends on the declared types of the expression, which is the opposite of what Equals does:

Option Strict On

Public Class CatalogItem
    Public ReadOnly Property Title As String

    Public Sub New(title As String)
        Me.Title = title
    End Sub

    Public Shared Operator =(left As CatalogItem, right As CatalogItem) As Boolean
        If left Is Nothing OrElse right Is Nothing Then
            Return left Is right
        End If
        Return left.Title = right.Title
    End Operator

    Public Shared Operator <>(left As CatalogItem, right As CatalogItem) As Boolean
        Return Not (left = right)
    End Operator

    Public Overrides Function Equals(obj As Object) As Boolean
        Dim other = TryCast(obj, CatalogItem)
        Return other IsNot Nothing AndAlso Title = other.Title
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return Title.GetHashCode()
    End Function
End Class

Public Class Book
    Inherits CatalogItem

    Public ReadOnly Property Isbn As String

    Public Sub New(title As String, isbn As String)
        MyBase.New(title)
        Me.Isbn = isbn
    End Sub

    Public Shared Overloads Operator =(left As Book, right As Book) As Boolean
        If left Is Nothing OrElse right Is Nothing Then
            Return left Is right
        End If
        Return left.Isbn = right.Isbn
    End Operator

    Public Shared Overloads Operator <>(left As Book, right As Book) As Boolean
        Return Not (left = right)
    End Operator

    Public Overrides Function Equals(obj As Object) As Boolean
        Dim other = TryCast(obj, Book)
        Return other IsNot Nothing AndAlso Isbn = other.Isbn
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return Isbn.GetHashCode()
    End Function
End Class

Module Program
    Sub Main()
        Dim first As New Book("Refactoring", "9780134757599")
        Dim second As New Book("Refactoring", "9780321127426")   ' same title, other edition

        Console.WriteLine("Book = Book:             " & (first = second))

        Dim itemA As CatalogItem = first
        Dim itemB As CatalogItem = second
        Console.WriteLine("CatalogItem = CatalogItem: " & (itemA = itemB))
        Console.WriteLine("Equals (virtual):          " & itemA.Equals(itemB))
        Console.WriteLine("Book.Equals(Book):         " & first.Equals(second))
        Console.WriteLine("same hash code:            " & (itemA.GetHashCode() = itemB.GetHashCode()))
    End Sub
End Module
Book = Book:             False
CatalogItem = CatalogItem: True
Equals (virtual):          False
Book.Equals(Book):         False
same hash code:            False

Source: Overloads

first = second is False because both operands are Book and the operator declared in Book compares the ISBN. The same two objects seen through CatalogItem variables compare by title and are True. The virtual Equals, called through either type, gives one answer (here the ISBN rule, because Book overrides it). The lesson is general: if a class hierarchy needs equality, put the logic in the virtual Equals and keep operators as thin wrappers over it, preferably declared once at the root of the hierarchy, or on a NotInheritable class. The Overloads keyword on the derived operators is required; the declaration without it is the BC40003 listed above, and the warning is the only hint that you meant to overload.

Operators and nullable value types

Operators declared for a structure are lifted to its nullable form (Money?, written Nullable(Of Money)): the compiler emits a call that unwraps both operands and, if either is Nothing, produces Nothing instead of calling your code. The rule is the same as for the built-in operators (see Nothing and Nullable Value Types):

Option Strict On

Public Structure Money
    Public ReadOnly Cents As Long

    Public Sub New(cents As Long)
        Me.Cents = cents
    End Sub

    Public Shared Operator +(left As Money, right As Money) As Money
        Console.WriteLine("  Money + Money called")
        Return New Money(left.Cents + right.Cents)
    End Operator

    Public Shared Operator =(left As Money, right As Money) As Boolean
        Console.WriteLine("  Money = Money called")
        Return left.Cents = right.Cents
    End Operator

    Public Shared Operator <>(left As Money, right As Money) As Boolean
        Return left.Cents <> right.Cents
    End Operator

    Public Shared Operator <(left As Money, right As Money) As Boolean
        Return left.Cents < right.Cents
    End Operator

    Public Shared Operator >(left As Money, right As Money) As Boolean
        Return left.Cents > right.Cents
    End Operator

    Public Overrides Function ToString() As String
        Return Cents.ToString() & "c"
    End Function
End Structure

Module Program
    Sub Main()
        Dim a As Money? = New Money(100)
        Dim b As Money? = New Money(250)
        Dim none As Money? = Nothing

        Console.WriteLine("a + b:")
        Dim sum = a + b
        Console.WriteLine("  = " & sum.ToString() & " (HasValue " & sum.HasValue & ")")

        Console.WriteLine("a + none:")
        Dim lifted = a + none
        Console.WriteLine("  HasValue: " & lifted.HasValue)

        Console.WriteLine("a = b:")
        Console.WriteLine("  " & (a = b).ToString())

        Console.WriteLine("a = none (the result is Nothing, not False):")
        Dim eq = (a = none)
        Console.WriteLine("  HasValue: " & eq.HasValue)
        Console.WriteLine("  as a condition: " & If(a = none, "true", "false"))
        Console.WriteLine("  a <> none as a condition: " & If(a <> none, "true", "false"))

        Console.WriteLine("a < b: " & (a < b).ToString())
        Console.WriteLine("a Is Nothing: " & (a Is Nothing))
        Console.WriteLine("none Is Nothing: " & (none Is Nothing))
    End Sub
End Module
a + b:
  Money + Money called
  = 350c (HasValue True)
a + none:
  HasValue: False
a = b:
  Money = Money called
  False
a = none (the result is Nothing, not False):
  HasValue: False
  as a condition: false
  a <> none as a condition: false
a < b: True
a Is Nothing: False
none Is Nothing: True

For comparison operators the lifted result is a Boolean? that is Nothing when either side is Nothing, and a Nothing condition is treated as False. So a = none and a <> none are both false in an If, which is why a null test on a nullable structure should be Is Nothing or HasValue, and why the operator’s side effect line is printed only when both operands have values. This is also the reason to keep operators free of side effects.

Operators inside statements

Several statements are defined in terms of operators, and they accept your types without further work if the operators exist:

Option Strict On

Public Structure Day
    Public ReadOnly Number As Integer

    Public Sub New(number As Integer)
        Me.Number = number
    End Sub

    Public Shared Operator +(left As Day, right As Day) As Day
        Return New Day(left.Number + right.Number)
    End Operator

    Public Shared Operator -(left As Day, right As Day) As Day
        Return New Day(left.Number - right.Number)
    End Operator

    Public Shared Operator <=(left As Day, right As Day) As Boolean
        Return left.Number <= right.Number
    End Operator

    Public Shared Operator >=(left As Day, right As Day) As Boolean
        Return left.Number >= right.Number
    End Operator

    Public Shared Operator =(left As Day, right As Day) As Boolean
        Console.WriteLine("  (= called)")
        Return left.Number = right.Number
    End Operator

    Public Shared Operator <>(left As Day, right As Day) As Boolean
        Return left.Number <> right.Number
    End Operator

    Public Shared Operator <(left As Day, right As Day) As Boolean
        Return left.Number < right.Number
    End Operator

    Public Shared Operator >(left As Day, right As Day) As Boolean
        Return left.Number > right.Number
    End Operator

    Public Overrides Function ToString() As String
        Return "day " & Number
    End Function
End Structure

Module Program
    Sub Main()
        ' For ... Next over a user-defined type needs +, -, <= and >=.
        For d As Day = New Day(1) To New Day(7) Step New Day(3)
            Console.WriteLine("For: " & d.ToString())
        Next

        ' Select Case uses = for Case x, and the relational operators for Is and To.
        Dim today As New Day(5)
        Select Case today
            Case New Day(1)
                Console.WriteLine("first day")
            Case Is >= New Day(5)
                Console.WriteLine("day 5 or later")
        End Select
        Select Case today
            Case New Day(1) To New Day(10)
                Console.WriteLine("within the first ten days")
        End Select
    End Sub
End Module
For: day 1
For: day 4
For: day 7
  (= called)
day 5 or later
within the first ten days
  • For d As Day = a To b Step c needs +, -, <= and >= on the type (the example declares no conversion from Integer, so none is needed). It runs at every step through your operators.

  • Select Case x followed by Case y evaluates x = y, hence the (= called) line; Case Is >= y uses the relational operator, and Case y To z uses >= and <=. See Control Flow.

  • Aggregate with a lambda, Function(a, b) a + b, works for any type that has the operator, because the lambda contains the + expression itself (see the generic-math section).

IsTrue, IsFalse and the short-circuit operators

IsTrue and IsFalse are the conversion of an object to a condition, and they are the building blocks of AndAlso and OrElse. They are matched, both must return Boolean, and you cannot declare AndAlso or OrElse yourself. To use them on your own type you must declare:

  • AndAlso: Operator And(left As T, right As T) As T and Operator IsFalse(x As T) As Boolean.

  • OrElse: Operator Or(left As T, right As T) As T and Operator IsTrue(x As T) As Boolean.

a AndAlso b then means: if IsFalse(a) then a, otherwise a And b; b is evaluated only in the second case:

Option Strict On

' A three-state check result whose AndAlso / OrElse short-circuit through IsFalse / IsTrue.
Public Structure Check
    Public ReadOnly Name As String
    Public ReadOnly Passed As Boolean

    Public Sub New(name As String, passed As Boolean)
        Me.Name = name
        Me.Passed = passed
    End Sub

    Public Shared Operator IsTrue(c As Check) As Boolean
        Return c.Passed
    End Operator

    Public Shared Operator IsFalse(c As Check) As Boolean
        Return Not c.Passed
    End Operator

    ' AndAlso needs And with this exact shape; OrElse needs Or.
    Public Shared Operator And(left As Check, right As Check) As Check
        Return New Check(left.Name & " and " & right.Name, left.Passed AndAlso right.Passed)
    End Operator

    Public Shared Operator Or(left As Check, right As Check) As Check
        Return New Check(left.Name & " or " & right.Name, left.Passed OrElse right.Passed)
    End Operator

    Public Shared Operator Not(c As Check) As Check
        Return New Check("not " & c.Name, Not c.Passed)
    End Operator

    Public Overrides Function ToString() As String
        Return Name & "=" & Passed
    End Function
End Structure

Module Program
    Function Probe(name As String, passed As Boolean) As Check
        Console.WriteLine("  evaluating " & name)
        Return New Check(name, passed)
    End Function

    Sub Main()
        Console.WriteLine("And (both sides always run):")
        Dim a = Probe("isbn-valid", False) And Probe("not-on-loan", True)
        Console.WriteLine("  -> " & a.ToString())

        Console.WriteLine("AndAlso (right side skipped when the left IsFalse):")
        Dim b = Probe("isbn-valid", False) AndAlso Probe("not-on-loan", True)
        Console.WriteLine("  -> " & b.ToString())

        Console.WriteLine("OrElse (right side skipped when the left IsTrue):")
        Dim c = Probe("member", True) OrElse Probe("staff", False)
        Console.WriteLine("  -> " & c.ToString())

        Console.WriteLine("If condition uses IsTrue:")
        If Probe("fine-paid", True) Then
            Console.WriteLine("  can borrow")
        End If

        Console.WriteLine("Not: " & (Not Probe("overdue", False)).ToString())
    End Sub
End Module
And (both sides always run):
  evaluating isbn-valid
  evaluating not-on-loan
  -> isbn-valid and not-on-loan=False
AndAlso (right side skipped when the left IsFalse):
  evaluating isbn-valid
  -> isbn-valid=False
OrElse (right side skipped when the left IsTrue):
  evaluating member
  -> member=True
If condition uses IsTrue:
  evaluating fine-paid
  can borrow
  evaluating overdue
Not: not overdue=True

Source: IsTrue Operator

If Probe("fine-paid", True) Then also works: a type with IsTrue is usable as a condition without comparing it with anything. Overloading And and Or forces the bitwise meaning: if you define them on a type, you cannot also give the logical meaning. If And returns a different type, or its operands differ, AndAlso fails at the use site (the BC30311 in the table above). Three-state logic types are the traditional reason to implement this; most application types should not.

Bitwise and shift operators

Types that model sets of flags can define And, Or, Xor, Not and the shifts. Unlike an Enum with <Flags>, a structure can enforce a mask (the Not below keeps only the three defined rights) and carry methods:

Option Strict On

' A set of up to 8 permissions as a value type with logical and shift operators.
Public Structure RightSet
    Private ReadOnly _bits As Byte

    Public Sub New(bits As Byte)
        _bits = bits
    End Sub

    Public Shared ReadOnly Property Borrow As New RightSet(1)
    Public Shared ReadOnly Property Renew As New RightSet(2)
    Public Shared ReadOnly Property Reserve As New RightSet(4)

    Public Shared Operator Or(left As RightSet, right As RightSet) As RightSet
        Return New RightSet(left._bits Or right._bits)
    End Operator

    Public Shared Operator And(left As RightSet, right As RightSet) As RightSet
        Return New RightSet(left._bits And right._bits)
    End Operator

    Public Shared Operator Xor(left As RightSet, right As RightSet) As RightSet
        Return New RightSet(left._bits Xor right._bits)
    End Operator

    Public Shared Operator Not(value As RightSet) As RightSet
        Return New RightSet(CByte((Not value._bits) And &H7))     ' only the 3 defined rights
    End Operator

    Public Shared Operator <<(value As RightSet, count As Integer) As RightSet
        Return New RightSet(CByte((CInt(value._bits) << count) And &H7))
    End Operator

    Public Shared Operator >>(value As RightSet, count As Integer) As RightSet
        Return New RightSet(CByte(CInt(value._bits) >> count))
    End Operator

    ' With IsTrue/IsFalse, "If rights Then" means "at least one right".
    Public Shared Operator IsTrue(value As RightSet) As Boolean
        Return value._bits <> 0
    End Operator

    Public Shared Operator IsFalse(value As RightSet) As Boolean
        Return value._bits = 0
    End Operator

    Public Overrides Function ToString() As String
        Dim names As New List(Of String)
        If (_bits And 1) <> 0 Then names.Add("Borrow")
        If (_bits And 2) <> 0 Then names.Add("Renew")
        If (_bits And 4) <> 0 Then names.Add("Reserve")
        Return If(names.Count = 0, "(none)", String.Join("|", names))
    End Function
End Structure

Module Program
    Sub Main()
        Dim member = RightSet.Borrow Or RightSet.Renew
        Console.WriteLine("member:          " & member.ToString())

        Dim student = member
        student = student And Not RightSet.Renew
        Console.WriteLine("student:         " & student.ToString())

        Dim toggled = member Xor RightSet.Reserve
        Console.WriteLine("member Xor Reserve: " & toggled.ToString())

        Dim shifted = RightSet.Borrow << 1
        Console.WriteLine("Borrow << 1:     " & shifted.ToString())
        Console.WriteLine("Reserve >> 2:    " & (RightSet.Reserve >> 2).ToString())

        member = member Or RightSet.Reserve       ' there is no Or= in Visual Basic
        Console.WriteLine("member Or Reserve: " & member.ToString())

        Dim grown = RightSet.Borrow
        grown <<= 1                               ' <<= and >>= exist and use << and >>
        Console.WriteLine("grown <<= 1:     " & grown.ToString())

        Dim none = RightSet.Borrow And RightSet.Renew
        If none Then
            Console.WriteLine("none is true")
        Else
            Console.WriteLine("none is false (IsFalse)")
        End If

        ' AndAlso / OrElse need And / Or plus IsFalse / IsTrue, all declared above.
        Dim allowed = member AndAlso RightSet.Borrow
        Console.WriteLine("member AndAlso Borrow: " & allowed.ToString())
    End Sub
End Module
member:          Borrow|Renew
student:         Borrow
member Xor Reserve: Borrow|Renew|Reserve
Borrow << 1:     Renew
Reserve >> 2:    Borrow
member Or Reserve: Borrow|Renew|Reserve
grown <<= 1:     Renew
none is false (IsFalse)
member AndAlso Borrow: Borrow

student = student And Not RightSet.Renew shows the precedence: Not binds tighter than And. For plain flag sets, an Enum with the Flags attribute is simpler, see Enumerations. Note that there is no Or=; the shift compound assignments <<= and >>= do exist and call the shift operators.

Concatenation and Like

Concatenation (&) and pattern matching (Like) are Visual Basic operators with no C# counterpart, and a type can take them over like any other. Their op_ methods are op_Concatenate and op_Like:

Option Strict On

' A shelf mark such as "QA76.73" is a class letter pair plus a number.
Public Structure ShelfMark
    Public ReadOnly Section As String
    Public ReadOnly Number As String

    Public Sub New(section As String, number As String)
        Me.Section = section
        Me.Number = number
    End Sub

    ' Concatenation: the operator & is VB-only and has its own operator name (op_Concatenate).
    Public Shared Operator &(left As ShelfMark, suffix As String) As ShelfMark
        Return New ShelfMark(left.Section, left.Number & suffix)
    End Operator

    Public Shared Operator &(prefix As String, right As ShelfMark) As ShelfMark
        Return New ShelfMark(prefix & right.Section, right.Number)
    End Operator

    ' Like: the right operand is whatever the operator declares, here a pattern over the text form.
    Public Shared Operator Like(mark As ShelfMark, pattern As String) As Boolean
        Return mark.ToString() Like pattern
    End Operator

    Public Overrides Function ToString() As String
        Return Section & Number
    End Function
End Structure

Module Program
    Sub Main()
        Dim mark As New ShelfMark("QA", "76.73")

        Dim revised = mark & ".A"
        Dim moved = "Z-" & mark
        Console.WriteLine("mark & "".A"" = " & revised.ToString())
        Console.WriteLine("""Z-"" & mark = " & moved.ToString())

        Dim tail = mark
        tail &= ".B"                  ' compound assignment uses the & operator
        Console.WriteLine("mark &= "".B"" = " & tail.ToString())

        Console.WriteLine("mark Like ""QA*"" = " & (mark Like "QA*"))
        Console.WriteLine("mark Like ""Q[A-C]##.##"" = " & (mark Like "Q[A-C]##.##"))
        Console.WriteLine("mark Like ""PS*"" = " & (mark Like "PS*"))

        ' The built-in Like on strings follows Option Compare (Binary here).
        Console.WriteLine("""qa76"" Like ""QA*"" = " & ("qa76" Like "QA*"))
    End Sub
End Module
mark & ".A" = QA76.73.A
"Z-" & mark = Z-QA76.73
mark &= ".B" = QA76.73.B
mark Like "QA*" = True
mark Like "Q[A-C]##.##" = True
mark Like "PS*" = False
"qa76" Like "QA*" = False

Source: Like Operator

The right operand of an overloaded Like is whatever you declare, a String pattern here. The built-in Like on two strings is covered on Operators and Expressions, including how Option Compare changes it. Overloading & is rare; a method such as Append is clearer, and ToString plus interpolation is what most code needs.

Conversion operators: Widening and Narrowing CType

A conversion operator is always called CType, has exactly one operand, and converts to or from the containing type. The Widening or Narrowing keyword is mandatory (BC33017 otherwise) and expresses what you promise:

  • Widening conversions must never fail and lose no information; the compiler applies them implicitly, even with Option Strict On.

  • Narrowing conversions may fail or lose information; with Option Strict On they require an explicit CType (or a conversion function). Choose Narrowing whenever the operator can throw.

A conversion operator cannot convert a type to itself (BC33024), to a base type (BC33026), to an interface (BC33025) or to Object (BC33028): those conversions already exist through inheritance. A widening and a narrowing operator with the same source and target types are duplicates (BC30269). The operator names seen from other languages are op_Implicit (widening) and op_Explicit (narrowing).

Option Strict On

Public Structure Isbn
    Implements IEquatable(Of Isbn)

    Public ReadOnly Property Value As String

    Private Sub New(value As String)
        Me.Value = value
    End Sub

    Public Shared Function TryParse(text As String, ByRef result As Isbn) As Boolean
        Dim digits = New String(If(text, "").Where(AddressOf Char.IsLetterOrDigit).ToArray())
        If digits.Length = 13 AndAlso digits.All(AddressOf Char.IsDigit) AndAlso HasValidCheckDigit(digits) Then
            result = New Isbn(digits)
            Return True
        End If
        Return False
    End Function

    Public Shared Function Parse(text As String) As Isbn
        Dim result As Isbn = Nothing
        If Not TryParse(text, result) Then
            Throw New FormatException($"{text} is not a valid ISBN-13.")
        End If
        Return result
    End Function

    Private Shared Function HasValidCheckDigit(digits As String) As Boolean
        Dim sum = 0
        For i = 0 To 12
            Dim d = AscW(digits(i)) - AscW("0"c)
            sum += If(i Mod 2 = 0, d, d * 3)
        Next
        Return sum Mod 10 = 0
    End Function

    ' Every ISBN converts to String without loss, so the conversion is Widening (implicit).
    Public Shared Widening Operator CType(value As Isbn) As String
        Return value.Value
    End Operator

    ' A String may not be a valid ISBN, so the conversion is Narrowing (explicit, may throw).
    Public Shared Narrowing Operator CType(text As String) As Isbn
        Return Parse(text)
    End Operator

    Public Overloads Function Equals(other As Isbn) As Boolean Implements IEquatable(Of Isbn).Equals
        Return Value = other.Value
    End Function

    Public Overrides Function Equals(obj As Object) As Boolean
        Return TypeOf obj Is Isbn AndAlso Equals(DirectCast(obj, Isbn))
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return If(Value, "").GetHashCode()
    End Function
End Structure

Module Program
    Sub Main()
        Dim isbn As Isbn = CType("978-0-13-468599-1", Isbn)

        ' Widening: no cast needed, not even under Option Strict On.
        Dim text As String = isbn
        Console.WriteLine("widened: " & text)

        ' Overload resolution can pick the user-defined widening conversion.
        Console.Write("Console.WriteLine(isbn): ")
        Console.WriteLine(isbn)
        Console.WriteLine("explicit CStr: " & CStr(isbn))

        ' Narrowing needs CType (the operator runs, so it may throw).
        Try
            Dim bad = CType("not an isbn", Isbn)
            Console.WriteLine(bad)
        Catch ex As FormatException
            Console.WriteLine("CType threw: " & ex.Message)
        End Try

        ' DirectCast and TryCast never call user-defined conversions.
        Dim boxed As Object = text
        Try
            Dim viaDirectCast = DirectCast(boxed, Isbn)
            Console.WriteLine(viaDirectCast)
        Catch ex As InvalidCastException
            Console.WriteLine("DirectCast threw: " & ex.GetType().Name)
        End Try
        Dim viaTryCast = TryCast(boxed, String)
        Console.WriteLine("TryCast to String: " & viaTryCast)

        ' CType on an Object that holds the right type just unboxes.
        Dim fromObject = CType(CObj(isbn), Isbn)
        Console.WriteLine("unboxed equals: " & fromObject.Equals(isbn))
    End Sub
End Module
widened: 9780134685991
Console.WriteLine(isbn): 9780134685991
explicit CStr: 9780134685991
CType threw: not an isbn is not a valid ISBN-13.
DirectCast threw: InvalidCastException
TryCast to String: 9780134685991
unboxed equals: True

What the output shows:

  • Dim text As String = isbn needs no cast: the widening operator applies implicitly. Console.WriteLine(isbn) also printed the digits, not the type name, because overload resolution accepted the user-defined conversion to String for the WriteLine(String) overload, which beats WriteLine(Object). An implicit conversion to String is therefore not harmless: it silently changes which overload an existing call binds to. Prefer overriding ToString and, if you want conversions at all, a Narrowing direction from String plus an explicit method (Parse) for the other.

  • CType("not an isbn", Isbn) runs the operator, which parsed and threw FormatException. That is the reason it is Narrowing.

  • DirectCast and TryCast never run user-defined conversions: they test the run-time type only. DirectCast(boxed, Isbn) where boxed holds a String throws InvalidCastException. Of the cast forms only CType and the C... functions (CStr, CInt, …​) use conversion operators. See Type Conversions for the full conversion tables.

  • & and the other operators do not apply user-defined conversions to their operands (the "x" & a error above).

The Narrowing operator to String-like targets should have a counterpart TryParse: exceptions are a poor way to report bad input, and the shelf Isbn offers both.

Equality: what the operators and methods compare

Before implementing equality, be clear about what each form asks:

Form Compares Notes

a Is b, a IsNot b

references

only for reference types and nullable value types; never calls user code

Object.ReferenceEquals(a, b)

references

boxes value types: two boxes are never the same object

a = b, a <> b

whatever the declared types define

built-in for primitives, String (honouring Option Compare), enumerations and Date; your Operator = otherwise; an error when none exists

a.Equals(b)

the virtual Equals(Object), or the Equals(T) overload that IEquatable(Of T) provides

a NullReferenceException when a is Nothing

Object.Equals(a, b)

Nothing-safe: references first, then a.Equals(b)

what generic code uses when it cannot assume non-null

EqualityComparer(Of T).Default.Equals(a, b)

IEquatable(Of T) if implemented, else Equals(Object)

what List, Dictionary, HashSet and Distinct use

StringComparer.X.Equals, String.Equals(a, b, comparison)

a named string comparison

independent of Option Compare

Reference and value equality

Option Strict On

Public Class Reader
    Public ReadOnly Property Name As String

    Public Sub New(name As String)
        Me.Name = name
    End Sub
End Class

Public Structure Point
    Public ReadOnly X As Integer
    Public ReadOnly Y As Integer

    Public Sub New(x As Integer, y As Integer)
        Me.X = x
        Me.Y = y
    End Sub
End Structure

Module Program
    Sub Main()
        ' A class with no overrides: Equals and Is both mean "the same object".
        Dim r1 As New Reader("Ana")
        Dim r2 As New Reader("Ana")
        Dim r3 = r1
        Console.WriteLine("class: r1 Is r2 = " & (r1 Is r2) & ", r1.Equals(r2) = " & r1.Equals(r2))
        Console.WriteLine("class: r1 Is r3 = " & (r1 Is r3) & ", r1.Equals(r3) = " & r1.Equals(r3))

        ' A structure with no overrides: ValueType.Equals compares the fields.
        Dim p1 As New Point(1, 2)
        Dim p2 As New Point(1, 2)
        Console.WriteLine("structure: p1.Equals(p2) = " & p1.Equals(p2))
        ' Boxing creates a new object each time, and Visual Basic also copies a boxed structure
        ' whenever an Object variable is passed or assigned (RuntimeHelpers.GetObjectValue).
        Dim box1 As Object = p1
        Dim box2 As Object = box1
        Console.WriteLine("structure: box1 Is box1 = " & (box1 Is box1))
        Console.WriteLine("structure: box1 Is box2 = " & (box1 Is box2))
        Console.WriteLine("structure: ReferenceEquals(box1, box1) = " & Object.ReferenceEquals(box1, box1))

        ' Strings are reference types with value semantics for = and Equals.
        Dim literal1 = "shelf"
        Dim literal2 = "shelf"
        Dim built = New String("shelf".ToCharArray())
        Console.WriteLine("string: literals Is = " & (literal1 Is literal2) & " (interned)")
        Console.WriteLine("string: built Is literal = " & (built Is literal1))
        Console.WriteLine("string: built = literal = " & (built = literal1))
        Console.WriteLine("string: built.Equals(literal) = " & built.Equals(literal1))
        Console.WriteLine("string: Intern(built) Is literal = " & (String.Intern(built) Is literal1))

        ' Object.Equals(a, b) handles Nothing and then calls a.Equals(b).
        Dim missing As Reader = Nothing
        Console.WriteLine("Object.Equals(Nothing, Nothing) = " & Object.Equals(missing, Nothing))
        Console.WriteLine("Object.Equals(r1, Nothing) = " & Object.Equals(r1, missing))
        Console.WriteLine("missing Is Nothing = " & (missing Is Nothing) & ", r1 IsNot Nothing = " & (r1 IsNot Nothing))
        Try
            Console.WriteLine(missing.Equals(r1))
        Catch ex As NullReferenceException
            Console.WriteLine("missing.Equals(r1) threw " & ex.GetType().Name)
        End Try
    End Sub
End Module
class: r1 Is r2 = False, r1.Equals(r2) = False
class: r1 Is r3 = True, r1.Equals(r3) = True
structure: p1.Equals(p2) = True
structure: box1 Is box1 = True
structure: box1 Is box2 = False
structure: ReferenceEquals(box1, box1) = False
string: literals Is = True (interned)
string: built Is literal = False
string: built = literal = True
string: built.Equals(literal) = True
string: Intern(built) Is literal = True
Object.Equals(Nothing, Nothing) = True
Object.Equals(r1, Nothing) = False
missing Is Nothing = True, r1 IsNot Nothing = True
missing.Equals(r1) threw NullReferenceException
  • A class without overrides has reference equality in both Is and Equals; two Reader objects with the same name are different.

  • A structure without overrides gets ValueType.Equals, which compares field by field (for structures with reference fields this is typically slow and allocates, see the boxing example below). It works, but the structure still has no = operator. The analyzer CA2013 flags Object.ReferenceEquals called with a value type: it is on by default, and it was an error in these projects because warnings are treated as errors.

  • Literals of the same value are interned, so literal1 Is literal2 is True, while a string built at run time is a different object with the same content. Use = or String.Equals, never Is, for text.

  • The last pair of lines is a Visual Basic quirk. When an Object variable holding a boxed structure is assigned or passed, the compiler wraps it in RuntimeHelpers.GetObjectValue, which returns a copy of a mutable boxed value so that value semantics survive. box1 Is box2 is therefore False even though box2 was assigned from box1, and Object.ReferenceEquals(box1, box1) is False for the same reason; Is between a variable and itself does not copy, so box1 Is box1 is True. Never use reference equality on boxed value types.

  • Object.Equals(Nothing, Nothing) is True and Object.Equals(r1, Nothing) is False, but missing.Equals(r1) on a Nothing reference throws, exactly as in C#. Prefer the static form or the IsNot Nothing guard.

Strings and Option Compare

The comparison operators on String follow Option Compare, and nothing else does. A file with Option Compare Text makes =, <, >, Like, Select Case and InStr case-insensitive, while every String method keeps its own rules:

Option Compare Text
Option Strict On

Module Program
    Sub Main()
        Dim a = "Shelf"
        Dim b = "SHELF"

        ' With Option Compare Text, = < > and Like are case-insensitive;
        ' the String methods are not affected.
        Console.WriteLine("a = b                  : " & (a = b))
        Console.WriteLine("a.Equals(b)            : " & a.Equals(b))
        Console.WriteLine("String.Equals(a, b)    : " & String.Equals(a, b))
        Console.WriteLine("a Like ""sh*""           : " & (a Like "sh*"))
        Console.WriteLine("a < b                  : " & (a < b))
        Console.WriteLine("InStr(a, ""HEL"")        : " & InStr(a, "HEL"))
        Console.WriteLine("a.IndexOf(""HEL"")       : " & a.IndexOf("HEL"))
        Console.WriteLine("Select Case on a       : " & Classify(a))
    End Sub

    Function Classify(text As String) As String
        Select Case text
            Case "shelf"
                Return "matched ""shelf"""
            Case Else
                Return "no match"
        End Select
    End Function
End Module
a = b                  : True
a.Equals(b)            : False
String.Equals(a, b)    : False
a Like "sh*"           : True
a < b                  : False
InStr(a, "HEL")        : 2
a.IndexOf("HEL")       : -1
Select Case on a       : matched "shelf"

This is why equality code that must not depend on the file’s setting (hash keys, protocol keywords, file names) states its comparison explicitly with StringComparer or StringComparison, and why an Equals implementation must not use = on strings if the project could change Option Compare (the project property OptionCompare sets it for all files). See Option Statements.

Numbers: where =, Equals and CompareTo disagree

Option Strict On

Module Program
    Sub Main()
        ' Floating point: = follows IEEE 754, Equals and CompareTo give NaN a place in a total order.
        Dim nan = Double.NaN
        Console.WriteLine("NaN = NaN            : " & (nan = nan))
        Console.WriteLine("NaN.Equals(NaN)      : " & nan.Equals(nan))
        Console.WriteLine("NaN.CompareTo(NaN)   : " & nan.CompareTo(nan))
        Console.WriteLine("NaN < 1.0            : " & (nan < 1.0))
        Console.WriteLine("NaN.CompareTo(1.0)   : " & nan.CompareTo(1.0))
        Console.WriteLine("0.0 = -0.0           : " & (0.0 = -0.0))
        Console.WriteLine("0.1 + 0.2 = 0.3      : " & (0.1 + 0.2 = 0.3))
        Console.WriteLine("within 1E-9          : " & (Math.Abs(0.1 + 0.2 - 0.3) < 1.0E-9))

        ' Decimal ignores trailing zeros in comparisons.
        Dim one = 1.0D
        Dim oneAgain = 1.00D
        Console.WriteLine("1.0D = 1.00D         : " & (one = oneAgain) & " but prints " & one & " and " & oneAgain)
        Console.WriteLine("hash codes equal     : " & (one.GetHashCode() = oneAgain.GetHashCode()))

        ' = widens its operands; Equals does not.
        Dim i As Integer = 5
        Dim l As Long = 5
        Console.WriteLine("i = l                : " & (i = l))
        Console.WriteLine("i.Equals(l)          : " & i.Equals(l))
        Console.WriteLine("l.Equals(i)          : " & l.Equals(i))
        Console.WriteLine("Object.Equals(i, l)  : " & Object.Equals(i, l))
    End Sub
End Module
NaN = NaN            : False
NaN.Equals(NaN)      : True
NaN.CompareTo(NaN)   : 0
NaN < 1.0            : False
NaN.CompareTo(1.0)   : -1
0.0 = -0.0           : True
0.1 + 0.2 = 0.3      : False
within 1E-9          : True
1.0D = 1.00D         : True but prints 1.0 and 1.00
hash codes equal     : True
i = l                : True
i.Equals(l)          : False
l.Equals(i)          : True
Object.Equals(i, l)  : False
  • = on floating point numbers follows IEEE 754: NaN = NaN is False, 0.0 = -0.0 is True. Equals and CompareTo form a consistent total order instead, so NaN.Equals(NaN) is True and NaN sorts first. A hash set of doubles therefore holds one NaN.

  • 0.1 + 0.2 = 0.3 is False; compare with a tolerance. Decimal ignores trailing zeros when comparing and hashing, but ToString keeps them.

  • = widens its operands (i = l compares as Long). i.Equals(l) goes to Equals(Object) with a boxed Long, which is never an Integer, so it is False; l.Equals(i) binds to Long.Equals(Long) through a widening conversion and is True. Equality across numeric types is therefore not symmetric in method form, which is one more reason to compare with = or to convert explicitly. See Numbers and Math.

Implementing value equality

The contract that every collection relies on:

  1. Reflexive, symmetric, transitive: x.Equals(x), x.Equals(y) = y.Equals(x), and equality chains.

  2. Consistent: the same answer while the compared state does not change.

  3. Never throws for a Nothing argument, and x.Equals(Nothing) is False.

  4. Hash consistent: equal objects return equal GetHashCode() values; unequal objects may collide.

  5. IEquatable(Of T).Equals, Equals(Object), =/<> and CompareTo = 0 agree.

For a structure, the complete set is IEquatable(Of T), both Equals methods, GetHashCode, and the =/<> pair, as in the shelf Isbn:

Option Strict On

Public Structure Isbn
    Implements IEquatable(Of Isbn)

    Public ReadOnly Property Value As String

    Private Sub New(value As String)
        Me.Value = value
    End Sub

    Public Shared Function TryParse(text As String, ByRef result As Isbn) As Boolean
        Dim digits = New String(If(text, "").Where(AddressOf Char.IsLetterOrDigit).ToArray())
        If digits.Length = 13 AndAlso digits.All(AddressOf Char.IsDigit) AndAlso HasValidCheckDigit(digits) Then
            result = New Isbn(digits)
            Return True
        End If
        Return False
    End Function

    Public Shared Function Parse(text As String) As Isbn
        Dim result As Isbn = Nothing
        If Not TryParse(text, result) Then
            Throw New FormatException($"{text} is not a valid ISBN-13.")
        End If
        Return result
    End Function

    Private Shared Function HasValidCheckDigit(digits As String) As Boolean
        Dim sum = 0
        For i = 0 To 12
            Dim d = AscW(digits(i)) - AscW("0"c)
            sum += If(i Mod 2 = 0, d, d * 3)
        Next
        Return sum Mod 10 = 0
    End Function
    ' IEquatable(Of Isbn): the strongly typed Equals, no boxing in generic collections.
    Public Overloads Function Equals(other As Isbn) As Boolean Implements IEquatable(Of Isbn).Equals
        Return String.Equals(Value, other.Value, StringComparison.Ordinal)
    End Function

    ' Object.Equals: the override that non-generic code and old collections call.
    Public Overrides Function Equals(obj As Object) As Boolean
        Return TypeOf obj Is Isbn AndAlso Equals(DirectCast(obj, Isbn))
    End Function

    ' Equal values must give equal hash codes; use only the fields that Equals uses.
    Public Overrides Function GetHashCode() As Integer
        Return If(Value, "").GetHashCode()
    End Function

    Public Overrides Function ToString() As String
        Return If(Value, "(no ISBN)")
    End Function

    Public Shared Operator =(left As Isbn, right As Isbn) As Boolean
        Return left.Equals(right)
    End Operator

    Public Shared Operator <>(left As Isbn, right As Isbn) As Boolean
        Return Not left.Equals(right)
    End Operator
End Structure

Module Program
    Sub Main()
        Dim a = Isbn.Parse("978-0-13-468599-1")
        Dim b = Isbn.Parse("9780134685991")
        Dim c = Isbn.Parse("978-0-13-475759-9")
        Dim unset As Isbn = Nothing

        Console.WriteLine("a = b: " & (a = b) & ", a <> c: " & (a <> c))
        Console.WriteLine("a.Equals(b): " & a.Equals(b))
        Console.WriteLine("a.Equals(CObj(b)): " & a.Equals(CObj(b)))
        Console.WriteLine("a.Equals(""9780134685991""): " & a.Equals("9780134685991"))
        Console.WriteLine("a.Equals(Nothing): " & a.Equals(Nothing))
        Console.WriteLine("unset = unset: " & (unset = unset) & " (prints " & unset.ToString() & ")")
        Console.WriteLine("a hash = b hash: " & (a.GetHashCode() = b.GetHashCode()))

        Dim seen As New HashSet(Of Isbn) From {a, b, c}
        Console.WriteLine("HashSet count: " & seen.Count)

        Dim titles As New Dictionary(Of Isbn, String)
        titles(a) = "first entry"
        titles(b) = "second entry"
        Console.WriteLine("Dictionary count: " & titles.Count & ", value: " & titles(a))

        Dim list As New List(Of Isbn) From {a, c}
        Console.WriteLine("List.Contains(b): " & list.Contains(b))
        Console.WriteLine("List.IndexOf(c): " & list.IndexOf(c))
        Console.WriteLine("Distinct count: " & New Isbn() {a, b, c, a}.Distinct().Count())
    End Sub
End Module
a = b: True, a <> c: True
a.Equals(b): True
a.Equals(CObj(b)): True
a.Equals("9780134685991"): False
a.Equals(Nothing): False
unset = unset: True (prints (no ISBN))
a hash = b hash: True
HashSet count: 2
Dictionary count: 1, value: second entry
List.Contains(b): True
List.IndexOf(c): 1
Distinct count: 2

Details that the output exercises:

  • Equals(other As Isbn) needs the Overloads keyword because the type also overrides Equals(Object): without it the compiler reports BC31409, plus warning BC40005 for the shadowed ValueType.Equals.

  • Equals(Object) must accept anything and return False for a foreign type: a.Equals("9780134685991") is False even though the text is the ISBN’s.

  • The default value of the structure (unset) has Value = Nothing, so every member must be null-safe. ToString and GetHashCode use If(Value, "") here.

  • HashSet, Dictionary, List.Contains, IndexOf, Distinct and Contains all found the equal ISBN, because they use EqualityComparer(Of Isbn).Default, which calls IEquatable(Of Isbn).Equals. None of them called the = operator.

  • GetHashCode hashes the same field Equals compares. In .NET Core and later HashCode.Combine(a, b, ...) is the recommended way to hash several fields; String.GetHashCode() is randomised per process, so hash codes must never be stored or compared across runs.

The compiler does not help you here. Overriding Equals without GetHashCode, defining = without overriding Equals, or the reverse, produces no diagnostic with the default analyzer settings of the SDK (checked: none of the three raises a warning), unlike C# (CS0659, CS0660 and CS0661). The .NET code analysis rules do cover part of this: CA1066 (implement IEquatable(Of T) when overriding Equals), CA1067 (override Equals when implementing IEquatable(Of T)) and CA2231 (overload the equality operator when overriding ValueType.Equals). Switch on the ones you want for equality-heavy code. See XML Documentation, Analyzers and Code Style.

Equality for a class

A class has the same members, with three additions: the Nothing checks, NotInheritable (or a carefully designed GetType() comparison) to avoid asymmetric equality with subclasses, and the decision whether equality is identity (default) or entity/value equality. A catalogue Book is naturally identified by its ISBN:

Option Strict On

' Entity-style equality: two Book objects are the same book when their ISBN text is equal.
Public NotInheritable Class Book
    Implements IEquatable(Of Book)

    Public ReadOnly Property Isbn As String
    Public Property Title As String

    Public Sub New(isbn As String, title As String)
        Me.Isbn = isbn
        Me.Title = title
    End Sub

    Public Overloads Function Equals(other As Book) As Boolean Implements IEquatable(Of Book).Equals
        If other Is Nothing Then Return False
        If Object.ReferenceEquals(Me, other) Then Return True
        Return String.Equals(Isbn, other.Isbn, StringComparison.Ordinal)
    End Function

    Public Overrides Function Equals(obj As Object) As Boolean
        Return Equals(TryCast(obj, Book))
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return Isbn.GetHashCode()
    End Function

    ' Operators on a class are called with Nothing too: never dereference blindly.
    Public Shared Operator =(left As Book, right As Book) As Boolean
        If left Is Nothing Then Return right Is Nothing
        Return left.Equals(right)
    End Operator

    Public Shared Operator <>(left As Book, right As Book) As Boolean
        Return Not (left = right)
    End Operator
End Class

Module Program
    Sub Main()
        Dim a As New Book("9780134685991", "Effective Java")
        Dim b As New Book("9780134685991", "Effective Java, third edition")
        Dim none As Book = Nothing

        Console.WriteLine("a = b: " & (a = b) & "  (different Title, same ISBN)")
        Console.WriteLine("a Is b: " & (a Is b))
        Console.WriteLine("a = Nothing: " & (a = Nothing))
        Console.WriteLine("none = Nothing: " & (none = Nothing))
        Console.WriteLine("Nothing = a: " & (Nothing = a))
        Console.WriteLine("a.Equals(Nothing): " & a.Equals(Nothing))
        Console.WriteLine("a.Equals(""9780134685991""): " & a.Equals("9780134685991"))

        ' Is Nothing never calls the operator, so it stays the safest null test.
        Console.WriteLine("none Is Nothing: " & (none Is Nothing))

        Dim shelf As New HashSet(Of Book) From {a, b}
        Console.WriteLine("HashSet count: " & shelf.Count)
        Console.WriteLine("Contains a copy: " & shelf.Contains(New Book("9780134685991", "x")))
    End Sub
End Module
a = b: True  (different Title, same ISBN)
a Is b: False
a = Nothing: False
none = Nothing: True
Nothing = a: False
a.Equals(Nothing): False
a.Equals("9780134685991"): False
none Is Nothing: True
HashSet count: 1
Contains a copy: True

Operator procedures on a class are called with Nothing operands, because a = Nothing binds to your operator, so the first line of each comparison tests Nothing. Write x Is Nothing for null tests, never x = Nothing: with an overloaded = the latter runs your code, and it is True only if your code says so. For a deep-dive on Nothing see Nothing and Nullable Value Types.

Mutable members and hash codes

An object that is inside a hash-based collection must not change the state GetHashCode is computed from:

Option Strict On

' A deliberately flawed class: the hash code depends on a property that can change.
Public NotInheritable Class Borrower
    Implements IEquatable(Of Borrower)

    Public Property Name As String

    Public Sub New(name As String)
        Me.Name = name
    End Sub

    Public Overloads Function Equals(other As Borrower) As Boolean Implements IEquatable(Of Borrower).Equals
        Return other IsNot Nothing AndAlso Name = other.Name
    End Function

    Public Overrides Function Equals(obj As Object) As Boolean
        Return Equals(TryCast(obj, Borrower))
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return Name.GetHashCode()
    End Function
End Class

Module Program
    Sub Main()
        Dim ana As New Borrower("Ana")
        Dim members As New HashSet(Of Borrower) From {ana}
        Dim byBorrower As New Dictionary(Of Borrower, Integer) From {{ana, 3}}
        Console.WriteLine("before: Contains = " & members.Contains(ana))

        ana.Name = "Ana Maria"          ' the key changed while it was inside the collections

        Console.WriteLine("after:  Contains = " & members.Contains(ana))
        Console.WriteLine("after:  Remove   = " & members.Remove(ana))
        Console.WriteLine("count is still " & members.Count)
        Console.WriteLine("dictionary lookup: " & byBorrower.ContainsKey(ana))
        Console.WriteLine("but enumeration finds it: " & members.Any(Function(m) m Is ana))
        Console.WriteLine("and a linear scan agrees: " & members.ToList().Contains(ana))
    End Sub
End Module
before: Contains = True
after:  Contains = False
after:  Remove   = False
count is still 1
dictionary lookup: False
but enumeration finds it: True
and a linear scan agrees: True

The set still has the object (count 1, enumeration finds it) but cannot find it by lookup, so Contains and Remove both fail, and the object can no longer be removed except by clearing. The hash must be computed from immutable state: ReadOnly properties, set in the constructor. If the identity can legitimately change, do not use the type as a key: key by an immutable ID instead. Collection types are on Collections.

Structures and boxing

A structure that does not implement IEquatable(Of T) pays for boxing on every comparison made through EqualityComparer(Of T).Default. The program counts allocations, not time, so the result is deterministic:

Option Strict On

' No overrides: equality comes from ValueType.Equals and boxes the argument.
Public Structure PlainCopy
    Public Isbn As String
    Public Copy As Integer
End Structure

' IEquatable(Of T) + overrides: EqualityComparer(Of T).Default compares without boxing.
Public Structure FastCopy
    Implements IEquatable(Of FastCopy)

    Public Isbn As String
    Public Copy As Integer

    Public Overloads Function Equals(other As FastCopy) As Boolean Implements IEquatable(Of FastCopy).Equals
        Return Isbn = other.Isbn AndAlso Copy = other.Copy
    End Function

    Public Overrides Function Equals(obj As Object) As Boolean
        Return TypeOf obj Is FastCopy AndAlso Equals(DirectCast(obj, FastCopy))
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return HashCode.Combine(Isbn, Copy)
    End Function
End Structure

Module Program
    Function Allocated(Of T)(items As List(Of T), probe As T) As Long
        items.Contains(probe)                       ' warm up: JIT, caches
        Dim before = GC.GetAllocatedBytesForCurrentThread()
        For i = 1 To 1000
            items.Contains(probe)
        Next
        Return GC.GetAllocatedBytesForCurrentThread() - before
    End Function

    Sub Main()
        Dim plain As New List(Of PlainCopy)
        Dim fast As New List(Of FastCopy)
        For i = 1 To 20
            plain.Add(New PlainCopy With {.Isbn = "9780134685991", .Copy = i})
            fast.Add(New FastCopy With {.Isbn = "9780134685991", .Copy = i})
        Next

        Console.WriteLine("PlainCopy.Contains finds copy 20: " & plain.Contains(New PlainCopy With {.Isbn = "9780134685991", .Copy = 20}))
        Console.WriteLine("FastCopy.Contains finds copy 20:  " & fast.Contains(New FastCopy With {.Isbn = "9780134685991", .Copy = 20}))

        Dim plainBytes = Allocated(plain, New PlainCopy With {.Isbn = "9780134685991", .Copy = 99})
        Dim fastBytes = Allocated(fast, New FastCopy With {.Isbn = "9780134685991", .Copy = 99})
        Console.WriteLine("PlainCopy allocates while comparing: " & (plainBytes > 0))
        Console.WriteLine("FastCopy allocates while comparing:  " & (fastBytes > 0))
    End Sub
End Module
PlainCopy.Contains finds copy 20: True
FastCopy.Contains finds copy 20:  True
PlainCopy allocates while comparing: True
FastCopy allocates while comparing:  False

The same applies to enumerations of structures in Dictionary and HashSet keys. A structure with only one or two fields, used as a collection key, should always implement IEquatable(Of T).

Tuples and anonymous types as shortcuts

Visual Basic has no record declaration to generate equality for you, but two built-in forms provide it. ValueTuple compares element by element and hashes accordingly, and an anonymous type compares only its Key properties:

Option Strict On

Public NotInheritable Class Edition
    Implements IEquatable(Of Edition)

    Public ReadOnly Property Isbn As String
    Public ReadOnly Property Number As Integer
    Public Property Note As String = ""

    Public Sub New(isbn As String, number As Integer)
        Me.Isbn = isbn
        Me.Number = number
    End Sub

    ' ValueTuple does the field-by-field comparison and the hashing for us.
    Public Overloads Function Equals(other As Edition) As Boolean Implements IEquatable(Of Edition).Equals
        Return other IsNot Nothing AndAlso (Isbn, Number).Equals((other.Isbn, other.Number))
    End Function

    Public Overrides Function Equals(obj As Object) As Boolean
        Return Equals(TryCast(obj, Edition))
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return HashCode.Combine(Isbn, Number)
    End Function
End Class

Module Program
    Sub Main()
        Dim a As New Edition("9780134685991", 3) With {.Note = "reprint"}
        Dim b As New Edition("9780134685991", 3)
        Console.WriteLine("a.Equals(b): " & a.Equals(b) & ", same hash: " & (a.GetHashCode() = b.GetHashCode()))

        ' Tuples compare element by element with Equals.
        Dim t1 = (Id:=1, Title:="Refactoring")
        Dim t2 = (Id:=1, Title:="Refactoring")
        Console.WriteLine("t1.Equals(t2): " & t1.Equals(t2))
        Console.WriteLine("t1.Equals((1, ""Refactoring"")): " & t1.Equals((1, "Refactoring")))
        Console.WriteLine("t1.CompareTo(t2): " & t1.CompareTo(t2))

        ' Anonymous types: only Key properties take part in Equals and GetHashCode.
        Dim k1 = New With {Key .Isbn = "9780134685991", .Note = "a"}
        Dim k2 = New With {Key .Isbn = "9780134685991", .Note = "b"}
        Console.WriteLine("anonymous Key equality: " & k1.Equals(k2) & ", " & (k1.GetHashCode() = k2.GetHashCode()))
        Dim n1 = New With {.Isbn = "9780134685991"}
        Dim n2 = New With {.Isbn = "9780134685991"}
        Console.WriteLine("without Key:            " & n1.Equals(n2))
    End Sub
End Module
a.Equals(b): True, same hash: True
t1.Equals(t2): True
t1.Equals((1, "Refactoring")): True
t1.CompareTo(t2): 0
anonymous Key equality: True, True
without Key:            False

Source: Anonymous Types

Using a tuple inside Equals is a compact, correct implementation as long as the elements are themselves comparable. There is no tuple = operator, so t1 = t2 is rejected (see the error list). For these two features see Tuples and Anonymous Types.

Ordering with IComparable and comparers

IComparable(Of T) gives a type its natural order, returning a negative number, zero or a positive number. It is what List.Sort(), SortedSet, Array.Sort and LINQ OrderBy/Max/Min with no comparer use. The conventions:

  • Any instance sorts after Nothing; CompareTo(Nothing) returns a positive number rather than throwing.

  • CompareTo(other) = 0 should agree with Equals; where it does not (case-insensitive ordering, say) document it, because SortedSet would then collapse values that HashSet keeps apart.

  • Implement the non-generic IComparable as well only when old code (ArrayList, Array.Sort(Array)) must be supported, and make it throw ArgumentException for other types.

  • The four relational operators are declared as two pairs and written with one shared Compare helper, so that Nothing handling cannot drift.

Option Strict On

Public NotInheritable Class Issue
    Implements IComparable(Of Issue), IComparable, IEquatable(Of Issue)

    Public ReadOnly Property Year As Integer
    Public ReadOnly Property Number As Integer

    Public Sub New(year As Integer, number As Integer)
        Me.Year = year
        Me.Number = number
    End Sub

    ' Convention: any instance sorts after Nothing; two Nothings are equal.
    Public Function CompareTo(other As Issue) As Integer Implements IComparable(Of Issue).CompareTo
        If other Is Nothing Then Return 1
        Dim byYear = Year.CompareTo(other.Year)
        Return If(byYear <> 0, byYear, Number.CompareTo(other.Number))
    End Function

    ' The non-generic interface is used by ArrayList, Array.Sort(Array) and old APIs.
    Private Function CompareToObject(obj As Object) As Integer Implements IComparable.CompareTo
        If obj Is Nothing Then Return 1
        Dim other = TryCast(obj, Issue)
        If other Is Nothing Then
            Throw New ArgumentException("Object must be of type Issue.", NameOf(obj))
        End If
        Return CompareTo(other)
    End Function

    Public Overloads Function Equals(other As Issue) As Boolean Implements IEquatable(Of Issue).Equals
        Return other IsNot Nothing AndAlso CompareTo(other) = 0
    End Function

    Public Overrides Function Equals(obj As Object) As Boolean
        Return Equals(TryCast(obj, Issue))
    End Function

    Public Overrides Function GetHashCode() As Integer
        Return HashCode.Combine(Year, Number)
    End Function

    Public Overrides Function ToString() As String
        Return Year & "/" & Number
    End Function

    ' The operators handle Nothing through a shared helper so that the rules cannot drift.
    Private Shared Function Compare(left As Issue, right As Issue) As Integer
        If left Is Nothing Then Return If(right Is Nothing, 0, -1)
        Return left.CompareTo(right)
    End Function

    Public Shared Operator <(left As Issue, right As Issue) As Boolean
        Return Compare(left, right) < 0
    End Operator

    Public Shared Operator >(left As Issue, right As Issue) As Boolean
        Return Compare(left, right) > 0
    End Operator

    Public Shared Operator <=(left As Issue, right As Issue) As Boolean
        Return Compare(left, right) <= 0
    End Operator

    Public Shared Operator >=(left As Issue, right As Issue) As Boolean
        Return Compare(left, right) >= 0
    End Operator

    Public Shared Operator =(left As Issue, right As Issue) As Boolean
        Return Compare(left, right) = 0
    End Operator

    Public Shared Operator <>(left As Issue, right As Issue) As Boolean
        Return Compare(left, right) <> 0
    End Operator
End Class

Module Program
    Sub Main()
        Dim issues As New List(Of Issue) From {
            New Issue(2025, 11), New Issue(2024, 3), Nothing, New Issue(2025, 2), New Issue(2024, 3)
        }
        issues.Sort()
        Console.WriteLine("List.Sort:        " & String.Join(" ", issues.Select(Function(i) If(i Is Nothing, "(none)", i.ToString()))))

        Dim distinct As New SortedSet(Of Issue)(issues.Where(Function(i) i IsNot Nothing))
        Console.WriteLine("SortedSet count:  " & distinct.Count & " (duplicates collapse via CompareTo)")
        Console.WriteLine("Min / Max:        " & distinct.Min.ToString() & " / " & distinct.Max.ToString())

        Dim a As New Issue(2024, 3)
        Dim b As New Issue(2025, 2)
        Console.WriteLine("a < b: " & (a < b) & ", a >= b: " & (a >= b) & ", a = New Issue(2024, 3): " & (a = New Issue(2024, 3)))
        Console.WriteLine("Nothing < a: " & (Nothing < a))

        ' The non-generic interface rejects other types.
        Dim legacy As IComparable = a
        Try
            legacy.CompareTo("2024/3")
        Catch ex As ArgumentException
            Console.WriteLine("IComparable.CompareTo(String): " & ex.Message)
        End Try

        ' Enumerable.OrderBy, Max and Min use Comparer(Of Issue).Default, i.e. IComparable(Of Issue).
        Dim newest = New Issue() {a, b}.Max()
        Console.WriteLine("Enumerable.Max:   " & newest.ToString())
    End Sub
End Module
List.Sort:        (none) 2024/3 2024/3 2025/2 2025/11
SortedSet count:  3 (duplicates collapse via CompareTo)
Min / Max:        2024/3 / 2025/11
a < b: True, a >= b: False, a = New Issue(2024, 3): True
Nothing < a: True
IComparable.CompareTo(String): Object must be of type Issue. (Parameter 'obj')
Enumerable.Max:   2025/2

For several orderings use comparer objects, IComparer(Of T) for order and IEqualityComparer(Of T) for equality and hashing. They are passed to List.Sort, OrderBy, SortedSet, Distinct, Dictionary and HashSet; Comparer(Of T).Create and a plain lambda are enough for one-off orderings:

Option Strict On

Public Class Book
    Public ReadOnly Property Title As String
    Public ReadOnly Property Year As Integer

    Public Sub New(title As String, year As Integer)
        Me.Title = title
        Me.Year = year
    End Sub

    Public Overrides Function ToString() As String
        Return Title & " (" & Year & ")"
    End Function
End Class

' A comparer is a separate object: one type can have many orderings.
Public NotInheritable Class ByTitleComparer
    Implements IComparer(Of Book)

    Public Function Compare(x As Book, y As Book) As Integer Implements IComparer(Of Book).Compare
        If x Is y Then Return 0
        If x Is Nothing Then Return -1
        If y Is Nothing Then Return 1
        Return StringComparer.OrdinalIgnoreCase.Compare(x.Title, y.Title)
    End Function
End Class

Public NotInheritable Class ByTitleEquality
    Implements IEqualityComparer(Of Book)

    Public Overloads Function Equals(x As Book, y As Book) As Boolean Implements IEqualityComparer(Of Book).Equals
        If x Is y Then Return True
        If x Is Nothing OrElse y Is Nothing Then Return False
        Return StringComparer.OrdinalIgnoreCase.Equals(x.Title, y.Title)
    End Function

    Public Overloads Function GetHashCode(obj As Book) As Integer Implements IEqualityComparer(Of Book).GetHashCode
        Return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Title)
    End Function
End Class

Module Program
    Sub Main()
        Dim books As New List(Of Book) From {
            New Book("refactoring", 1999), New Book("Domain-Driven Design", 2003),
            New Book("Refactoring", 2018), New Book("Clean Code", 2008)
        }

        books.Sort(New ByTitleComparer())
        Console.WriteLine("IComparer:        " & String.Join("; ", books))

        books.Sort(Function(x, y) y.Year.CompareTo(x.Year))
        Console.WriteLine("Comparison:       " & String.Join("; ", books))

        Dim byYear = Comparer(Of Book).Create(Function(x, y) x.Year.CompareTo(y.Year))
        Console.WriteLine("Comparer.Create:  " & String.Join("; ", books.OrderBy(Function(b) b, byYear)))

        Console.WriteLine("OrderBy/ThenBy:   " & String.Join("; ", books.OrderBy(Function(b) b.Title, StringComparer.OrdinalIgnoreCase).ThenByDescending(Function(b) b.Year)))

        Console.WriteLine("Distinct by title: " & books.Distinct(New ByTitleEquality()).Count() & " of " & books.Count)

        Dim index As New Dictionary(Of Book, Integer)(New ByTitleEquality())
        For Each b In books
            index(b) = b.Year
        Next
        Console.WriteLine("Dictionary keys:  " & index.Count)

        ' Default equality for a class without overrides is reference equality.
        Console.WriteLine("default Distinct:  " & books.Distinct().Count())

        Dim names As New HashSet(Of String)(StringComparer.OrdinalIgnoreCase) From {"Ana", "ANA", "Luis"}
        Console.WriteLine("HashSet(Of String) with a comparer: " & names.Count)
    End Sub
End Module
IComparer:        Clean Code (2008); Domain-Driven Design (2003); refactoring (1999); Refactoring (2018)
Comparison:       Refactoring (2018); Clean Code (2008); Domain-Driven Design (2003); refactoring (1999)
Comparer.Create:  refactoring (1999); Domain-Driven Design (2003); Clean Code (2008); Refactoring (2018)
OrderBy/ThenBy:   Clean Code (2008); Domain-Driven Design (2003); Refactoring (2018); refactoring (1999)
Distinct by title: 3 of 4
Dictionary keys:  3
default Distinct:  4
HashSet(Of String) with a comparer: 2

List.Sort is not stable (the two titles that compare equal came out in an arbitrary order in the first line), while OrderBy is: use OrderBy/ThenBy when equal elements must keep their order. An IEqualityComparer(Of T) must keep the same contract as Equals: whatever Equals says is equal must hash equally.

Generic math: what Visual Basic cannot do

NET 7 added static abstract interface members to the language and runtime and used them for "generic math":

INumber(Of T), IAdditionOperators(Of TSelf, TOther, TResult) and friends, so that a generic method can write a + b. C# 11 consumes and implements them. Visual Basic 17.13 does neither:

Option Strict On

Imports System.Numerics

Module Program
    ' Compiles in C# 11 and later; Visual Basic cannot use the operator through the constraint.
    Function Total(Of T As IAdditionOperators(Of T, T, T))(a As T, b As T) As T
        Return a + b
    End Function

    Sub Main()
        Console.WriteLine(Total(1, 2))
    End Sub
End Module
Program.vb(8,16): error BC30452: Operator '+' is not defined for types 'T' and 'T'.

Source: Generic math

and a type cannot opt in either:

Option Strict On

Imports System.Numerics

Public Structure Meters
    Implements IAdditionOperators(Of Meters, Meters, Meters)

    Public ReadOnly Value As Double

    Public Sub New(value As Double)
        Me.Value = value
    End Sub

    Public Shared Operator +(left As Meters, right As Meters) As Meters
        Return New Meters(left.Value + right.Value)
    End Operator
End Structure

Module Program
    Sub Main()
        Console.WriteLine((New Meters(1) + New Meters(2)).Value)
    End Sub
End Module
Program.vb(6,16): error BC37315: Structure 'Meters' cannot implement interface 'IAdditionOperators(Of Meters, Meters, Meters)' because it contains shared abstract or virtual 'Function op_Addition(left As Meters, right As Meters) As Meters'.
Program.vb(6,16): error BC37315: Structure 'Meters' cannot implement interface 'IAdditionOperators(Of Meters, Meters, Meters)' because it contains shared abstract or virtual 'Function op_CheckedAddition(left As Meters, right As Meters) As Meters'.

Calling library APIs that use generic math internally is fine, for example Enumerable.Sum on built-in numeric types; what fails is declaring a generic method over the operator interfaces and implementing those interfaces. The VB-side workaround is to pass the operation in as a delegate (a Func(Of T, T, T)), or to use Aggregate with a lambda:

Option Strict On

Public Structure Meters
    Public ReadOnly Value As Double

    Public Sub New(value As Double)
        Me.Value = value
    End Sub

    Public Shared Operator +(left As Meters, right As Meters) As Meters
        Return New Meters(left.Value + right.Value)
    End Operator
End Structure

Module Program
    ' Visual Basic cannot call "a + b" through a generic-math constraint,
    ' so pass the operation in as a delegate.
    Function Total(Of T)(items As IEnumerable(Of T), zero As T, add As Func(Of T, T, T)) As T
        Dim result = zero
        For Each item In items
            result = add(result, item)
        Next
        Return result
    End Function

    Sub Main()
        ' The BCL's own generic-math based Sum works fine with the built-in numeric types.
        Console.WriteLine(Enumerable.Sum(New Integer() {1, 2, 3}))
        Console.WriteLine(New Double() {1.5, 2}.Sum())

        Dim lengths = {New Meters(1), New Meters(2.5), New Meters(3)}
        Dim sum = Total(lengths, New Meters(0), Function(a, b) a + b)
        Console.WriteLine(sum.Value)
        Console.WriteLine(lengths.Aggregate(Function(a, b) a + b).Value)
    End Sub
End Module
6
3.5
6.5
6.5

A C# library that offers generic math over its own types is usable from Visual Basic only through the concrete operators those types also declare (+, =, …​); the interface constraint itself cannot be satisfied or called from Visual Basic. Turning Option Strict off and late binding (Operators.AddObject) can add two Object values, but give up compile-time checking and are not used in this section (see Early and Late Binding).

Differences from C#

Topic Visual Basic C#

Declaration

Public Shared Operator +(a As T, b As T) As T

public static T operator +(T a, T b)

Equality operator

= and <>, pair mandatory (BC33033)

== and !=, pair mandatory

Missing GetHashCode or Equals

no diagnostic by default

warnings CS0659, CS0660, CS0661

Null test that skips the overload

x Is Nothing

x is null (x == null would call the overload)

record types

not declarable; use classes with IEquatable(Of T), tuples or Key anonymous types

value equality, ToString, with generated

Operators with no counterpart

\, ^, &, Like (callable from C# as op_ methods)

>>> (C# 11), checked operators (C# 11), user-defined compound assignment (C# 14)

AndAlso/OrElse

derived from And/IsFalse and Or/IsTrue

&& and || derived from & and true/false

Conversions

Widening/Narrowing Operator CType, used by CType and the C... functions

implicit/explicit, used by casts

DirectCast/TryCast

never use conversion operators

the as operator never does; a cast (T)x does

Static abstract interface members (generic math)

cannot declare, implement or call (BC37315, BC30452)

supported since C# 11

Operator in an interface

not possible (BC30603)

static abstract members since C# 11

== on String

case-sensitive unless Option Compare Text; one more thing the file header can change

always ordinal

+= on your type

rewritten as a = a + b

same; since C# 14 a user-defined += operator can mutate in place

Older material you may meet

Tutorials and the books this section was compared with often predate several of these points:

  • Examples that sort with the non-generic IComparable and ArrayList, or implement only Equals(Object): they still work, but use IComparable(Of T), IEquatable(Of T) and generic collections today, and make the structure’s Equals(Object) call the typed overload to avoid boxing.

  • "Operator overloading" chapters that stop at + and =. A type that defines = must also define <>, and should define Equals, GetHashCode and the IEquatable(Of T) implementation to match; the compiler will not remind you.

  • VB6 code compared objects with Is and relied on default properties and Variant comparison for =. In .NET, = on two class-typed operands with no operator is a compile error (BC30452), and Object = Object is BC32013 under Option Strict On.

  • Hash-code advice that XORs fields (a.GetHashCode() Xor b.GetHashCode()), which gives equal hashes for swapped values, predates HashCode.Combine.

  • Samples that compare Strings with = and assume the project is case-sensitive, ignoring Option Compare.

Common pitfalls

Pitfall What to do

Overrides Equals without GetHashCode

override both, from the same immutable fields; there is no compiler warning

= defined without <> (BC33033) or < without >

declare all pairs; implement <, >, <=, >= over a single CompareTo

Operator on a class dereferences Nothing

first line: If left Is Nothing Then Return right Is Nothing

Using x = Nothing as a null test on a class with =

use x Is Nothing

Derived class declares = without Overloads (BC40003)

put the logic in virtual Equals; keep operators at the root

Implicit Widening conversion to String changes overloads

prefer ToString; keep conversions Narrowing when in doubt

DirectCast/TryCast expected to run your conversion

use CType

ReferenceEquals or Is on boxed structures

compare values; Visual Basic copies boxes on assignment

Mutating a key inside a HashSet or Dictionary

make the hashed state ReadOnly

Or=, And=, Mod=

not in the language; write the expression out

Generic Sum(Of T) with + on T

pass a Func(Of T, T, T); Visual Basic has no generic math

Relying on List.Sort to keep ties in order

use OrderBy/ThenBy

Comparing String with = in Equals implementations

use String.Equals(a, b, StringComparison.Ordinal)