PONYλM2Modula-2

Visual Basic.CodeCompared.To/C#

An interactive executable cheatsheet comparing Visual Basic and C#

Visual Basic (.NET 10) C# 14 (.NET 10)
Output & Running
Hello, World
The smallest complete program in each language, side by side — before anything else, notice how much of the Visual Basic column has no counterpart at all.
Option Strict On Imports System Module HelloWorld Sub Main() Console.WriteLine("Hello, World!") End Sub End Module
Console.WriteLine("Hello, World!");
Both lines call the same Console.WriteLine from the same .NET base class library; only the packaging differs. C# still has Main — the compiler writes one for you and puts these statements inside it — so nothing was taken away, it was made implicit. The semicolon is not optional: C# ends every statement with one rather than using the end of the line.
No Module, no Sub Main
Where a Visual Basic program needs three levels of nesting before the first real line, a C# file can start with the work itself.
Option Strict On Imports System Module Program Sub Main() Dim total As Integer = 0 For counter As Integer = 1 To 5 total += counter Next Console.WriteLine($"Total: {total}") End Sub End Module
int total = 0; for (int counter = 1; counter <= 5; counter++) { total += counter; } Console.WriteLine($"Total: {total}");
Statements written straight into a file are called top-level statements, and only one file in a project may have them. Everything you would have put in Module Program goes here directly, and any classes or records you need are declared after the statements, at the bottom of the file. This is why every C# example on this page starts with executable code and defines its types last — the compiler requires that order.
String interpolation
One of the few places where you can copy a line across unchanged: interpolated strings are spelled identically in both languages.
Option Strict On Imports System Module InterpolationDemo Sub Main() Dim name As String = "Ada" Dim score As Integer = 42 Dim ratio As Double = 0.8756 Console.WriteLine($"Hello, {name}! Score: {score}") Console.WriteLine($"Padded: {score:D5}, rounded: {ratio:F2}") End Sub End Module
string name = "Ada"; int score = 42; double ratio = 0.8756; Console.WriteLine($"Hello, {name}! Score: {score}"); Console.WriteLine($"Padded: {score:D5}, rounded: {ratio:F2}");
The $"..." form and its format specifiers (D5, F2, N0, P1) came from the same compiler team and work the same way. If you are arriving from VB6 or VBA rather than VB.NET, this replaces the Format$() and &-chaining habit entirely, in both languages.
Writing without a newline
The same two output methods, and the first sighting of a difference that will follow you everywhere: C# keywords are lowercase.
Option Strict On Imports System Module OutputDemo Sub Main() Console.Write("Loading") For dot As Integer = 1 To 3 Console.Write(".") Next Console.WriteLine(" done") Console.WriteLine(True) Console.WriteLine(3.5) End Sub End Module
Console.Write("Loading"); for (int dot = 1; dot <= 3; dot++) { Console.Write("."); } Console.WriteLine(" done"); Console.WriteLine(true); Console.WriteLine(3.5);
Visual Basic capitalizes True and False; C# writes true and false, and will not accept the capitalized forms. That is not a style preference — C# is case-sensitive, so True is simply an unknown identifier. The same applies to Nothing, which becomes null.
Syntax Fundamentals
Blocks: braces instead of End
Every End Something you have ever typed collapses into a single closing brace.
Option Strict On Imports System Module BlockDemo Sub Main() Dim temperature As Integer = 30 If temperature > 25 Then Console.WriteLine("Warm") ElseIf temperature > 10 Then Console.WriteLine("Mild") Else Console.WriteLine("Cold") End If End Sub End Module
int temperature = 30; if (temperature > 25) { Console.WriteLine("Warm"); } else if (temperature > 10) { Console.WriteLine("Mild"); } else { Console.WriteLine("Cold"); }
The condition needs parentheses in C# and no Then; ElseIf becomes two words, else if. A block of exactly one statement may drop its braces entirely (if (x) DoThing();), which has no Visual Basic parallel except the single-line If ... Then ... form. Most C# codebases keep the braces anyway.
Comments and documentation
Three comment forms in Visual Basic, three in C# — but they do not line up one to one.
Option Strict On Imports System Module CommentDemo ''' <summary>Doubles a number.</summary> Function Double_(value As Integer) As Integer ' A line comment starts with an apostrophe REM REM also works, and nobody uses it Return value * 2 End Function Sub Main() Console.WriteLine(Double_(21)) End Sub End Module
Console.WriteLine(Double_(21)); /// <summary>Doubles a number.</summary> int Double_(int value) { // A line comment starts with two slashes /* and this form spans as many lines as you like */ return value * 2; }
C# gains a genuine block comment, /* ... */, which Visual Basic has never had; that is why long commented-out passages in Visual Basic are a column of apostrophes. Documentation comments survive the trip: ''' becomes ///, and the XML tags inside (<summary>, <param>, <returns>) are identical, because both compilers emit the same documentation file.
Case sensitivity
The single largest mental adjustment on this page, and the one most likely to bite silently.
Option Strict On Imports System Module CaseDemo Sub Main() Dim customerName As String = "Grace" ' Every one of these refers to the SAME variable Console.WriteLine(customerName) Console.WriteLine(CustomerName) Console.WriteLine(CUSTOMERNAME) End Sub End Module
string customerName = "Grace"; string CustomerName = "Hopper"; // Two different variables, and the compiler sees no problem Console.WriteLine(customerName); Console.WriteLine(CustomerName);
Visual Basic identifiers are case-insensitive — the editor even rewrites your casing to match the declaration — so customerName and CustomerName are one name. In C# they are two names, and nothing warns you. The convention that saves you is the one the .NET libraries already follow: camelCase for locals and parameters, PascalCase for types, methods, properties and constants. Follow it and the two never collide.
Long lines and statement ends
The trailing underscore disappears, and so does the need for it.
Option Strict On Imports System Module ContinuationDemo Sub Main() Dim total As Integer = 1 + 2 + 3 + 4 + 5 + 6 Dim label As String = String.Format( _ "Total is {0}", total) Console.WriteLine(label) End Sub End Module
int total = 1 + 2 + 3 + 4 + 5 + 6; string label = string.Format( "Total is {0}", total); Console.WriteLine(label);
C# ends a statement at the semicolon, not at the end of the line, so a statement may be broken across as many lines as you like with no continuation character at all. Visual Basic has drifted the same way — since VB 2010 most line breaks are inferred and the _ is only needed in the remaining awkward spots, as after an opening parenthesis in the example above. In C# there is no awkward spot.
Variables & Types
Declaring a variable
The declaration turns inside out: the type moves from the end of the line to the front, and Dim vanishes.
Option Strict On Imports System Module DeclarationDemo Sub Main() Dim count As Integer = 10 Dim price As Decimal = 19.99D Dim label As String = "widget" Dim ready As Boolean = True Console.WriteLine($"{count} x {label} at {price} (ready: {ready})") End Sub End Module
int count = 10; decimal price = 19.99m; string label = "widget"; bool ready = true; Console.WriteLine($"{count} x {label} at {price} (ready: {ready})");
Read Dim count As Integer right to left and you have C#'s int count. The literal suffixes change case: D for decimal becomes m (for "money", since d was taken by double), F becomes f, L stays L. There is no Option Explicit switch to forget — C# has never allowed an undeclared variable.
Type inference: Dim without As
If you already write Dim x = ... with Option Infer On, you have been writing C#'s var all along.
Option Strict On Option Infer On Imports System Imports System.Collections.Generic Module InferenceDemo Sub Main() Dim count = 10 Dim names = New List(Of String) From {"Ada", "Grace"} Console.WriteLine(count.GetType().Name) Console.WriteLine(String.Join(", ", names)) End Sub End Module
var count = 10; var names = new List<string> { "Ada", "Grace" }; Console.WriteLine(count.GetType().Name); Console.WriteLine(string.Join(", ", names));
Both are compile-time inference, not dynamic typing: count is an int forever and assigning a string to it is a compile error. The difference is that C# has no Option Infer to turn off, and no Option Strict either — its inference is always on and its strictness is never negotiable. Note also that the generic type argument moves from (Of String) to <string>.
What the built-in types are called
A translation table you will need for about a week, after which it is automatic.
Option Strict On Imports System Module TypeNameDemo Sub Main() Dim small As Short = 1S Dim whole As Integer = 2 Dim big As Long = 3L Dim rough As Single = 4.5F Dim precise As Double = 6.7 Dim exact As Decimal = 8.9D Dim letter As Char = "A"c Dim raw As Byte = 255 Console.WriteLine($"{small} {whole} {big} {rough} {precise} {exact} {letter} {raw}") End Sub End Module
short small = 1; int whole = 2; long big = 3L; float rough = 4.5f; double precise = 6.7; decimal exact = 8.9m; char letter = 'A'; byte raw = 255; Console.WriteLine($"{small} {whole} {big} {rough} {precise} {exact} {letter} {raw}");
Integerint, Longlong, Shortshort, Singlefloat, Doubledouble, Booleanbool, Stringstring, Objectobject. Both sets are aliases for the same .NET types (System.Int32 and the rest), so nothing changes at runtime. The one that catches people is Char: Visual Basic writes "A"c with a suffix, C# uses single quotes, and single quotes in C# are never a string.
Constants and read-only values
Both languages separate "baked into the compiled code" from "assigned once at startup", and both spell the distinction almost the same way.
Option Strict On Imports System Module ConstantDemo Const MaximumRetries As Integer = 3 Private ReadOnly StartedAt As DateTime = New DateTime(2026, 1, 1) Sub Main() Console.WriteLine(MaximumRetries) Console.WriteLine(StartedAt.Year) End Sub End Module
Console.WriteLine(Settings.MaximumRetries); Console.WriteLine(Settings.StartedAt.Year); static class Settings { public const int MaximumRetries = 3; public static readonly DateTime StartedAt = new DateTime(2026, 1, 1); }
Const stays const and must be a compile-time literal in both. ReadOnly becomes readonly, and is what you need for anything computed — a DateTime, a List, anything built with New. Note that a C# const is implicitly static, so it lives on the type rather than the instance, exactly as a Const in a Module does.
Nullable value types
The ? suffix means the same thing in both languages; what changes is Nothing.
Option Strict On Imports System Module NullableDemo Sub Main() Dim maybeCount As Integer? = Nothing If maybeCount.HasValue Then Console.WriteLine($"Got {maybeCount.Value}") Else Console.WriteLine("No value") End If Dim safe As Integer = maybeCount.GetValueOrDefault(-1) Console.WriteLine(safe) End Sub End Module
int? maybeCount = null; if (maybeCount.HasValue) { Console.WriteLine($"Got {maybeCount.Value}"); } else { Console.WriteLine("No value"); } int safe = maybeCount ?? -1; Console.WriteLine(safe);
Nothing becomes null, and the two are not quite the same idea. Assigning Nothing to an Integer in Visual Basic quietly stores zeroNothing means "the default value for this type". C# refuses: int x = null; is a compile error, and only int? can hold null. The ?? operator, read "or else use", is C#'s equivalent of the two-argument If(value, fallback).
Converting between types
The whole family of C-prefixed conversion functions has no C# counterpart — here is what replaces each kind.
Option Strict On Imports System Module ConversionDemo Sub Main() Dim text As String = "123" Dim parsed As Integer = CInt(text) Dim widened As Double = CDbl(parsed) Dim boxed As Object = parsed Dim unboxed As Integer = DirectCast(boxed, Integer) Dim notANumber As String = "oops" Dim result As Integer If Integer.TryParse(notANumber, result) Then Console.WriteLine(result) Else Console.WriteLine("Could not parse") End If Console.WriteLine($"{parsed} {widened} {unboxed}") End Sub End Module
string text = "123"; int parsed = int.Parse(text); double widened = parsed; object boxed = parsed; int unboxed = (int)boxed; string notANumber = "oops"; if (int.TryParse(notANumber, out int result)) { Console.WriteLine(result); } else { Console.WriteLine("Could not parse"); } Console.WriteLine($"{parsed} {widened} {unboxed}");
CInt, CDbl, CStr and friends split three ways in C#. Parsing text uses int.Parse / int.TryParse; a widening conversion such as int to double needs nothing at all; a narrowing or unboxing conversion uses a cast in parentheses, (int)boxed, which is DirectCast. TryCast — a cast that yields Nothing instead of throwing — is C#'s as operator, and works only on reference and nullable types.
Operators
= assigns, == compares
Visual Basic decides from context whether = assigns or compares. C# makes you choose.
Option Strict On Imports System Module EqualityDemo Sub Main() Dim left As Integer = 5 Dim right As Integer = 5 ' One symbol, two jobs, told apart by position If left = right Then Console.WriteLine("equal") End If If left <> 6 Then Console.WriteLine("not six") End If End Sub End Module
int left = 5; int right = 5; // Two symbols, one job each if (left == right) { Console.WriteLine("equal"); } if (left != 6) { Console.WriteLine("not six"); }
Comparison is == and inequality is != rather than <>. The classic C trap — writing if (x = 5) and assigning by accident — cannot happen here, because a C# if requires a bool and an assignment of an int yields an int, so the compiler rejects it. The same rule means if (count) is an error too: there is no truthiness in C#, and no 0 = False convention.
AndAlso and OrElse
Both languages have a short-circuiting pair and a non-short-circuiting pair; only the spelling changes.
Option Strict On Imports System Module LogicalDemo Function IsExpensive(value As Integer) As Boolean Console.WriteLine(" (checked)") Return value > 100 End Function Sub Main() Dim value As Integer = 5 ' AndAlso stops early — IsExpensive is never called If value > 10 AndAlso IsExpensive(value) Then Console.WriteLine("both") End If ' And evaluates BOTH sides, always If value > 10 And IsExpensive(value) Then Console.WriteLine("both") End If Console.WriteLine(Not (value = 5)) End Sub End Module
int value = 5; // && stops early — IsExpensive is never called if (value > 10 && IsExpensive(value)) { Console.WriteLine("both"); } // & evaluates BOTH sides, always if (value > 10 & IsExpensive(value)) { Console.WriteLine("both"); } Console.WriteLine(!(value == 5)); bool IsExpensive(int candidate) { Console.WriteLine(" (checked)"); return candidate > 100; }
AndAlso&&, OrElse||, And&, Or|, Xor^, Not!. The mapping is exact, including the trap: And and & both evaluate the right-hand side even when the left has already settled the answer, which is why the second If prints "(checked)". C# convention is to reach for && and || by default and treat & / | as bitwise operators, which is what they mostly are.
Integer division
Visual Basic has two division operators. C# has one, and which kind of division you get depends on the operands.
Option Strict On Imports System Module DivisionDemo Sub Main() Dim quotient As Integer = 17 \ 5 Dim exact As Double = 17 / 5 Dim remainder As Integer = 17 Mod 5 Console.WriteLine($"{quotient} {exact} {remainder}") End Sub End Module
int quotient = 17 / 5; double exact = 17 / 5.0; int remainder = 17 % 5; Console.WriteLine($"{quotient} {exact} {remainder}");
This is the single most dangerous line-for-line translation on the page. Visual Basic's / always produces a floating-point result and \ is the integer one. C# has no \: 17 / 5 between two ints truncates to 3, and you get the fractional answer only by making an operand floating-point — 17 / 5.0, or (double)a / b. A Visual Basic / copied straight across silently starts truncating. Mod becomes %.
Raising to a power
One of the few operators Visual Basic has and C# does not.
Option Strict On Imports System Module PowerDemo Sub Main() Dim squared As Double = 7 ^ 2 Dim root As Double = 81 ^ 0.5 Console.WriteLine($"{squared} {root}") End Sub End Module
double squared = Math.Pow(7, 2); double root = Math.Pow(81, 0.5); Console.WriteLine($"{squared} {root}");
C# has no exponent operator; Math.Pow is the answer, and it takes and returns double just as ^ does. Do not reach for ^ out of habit — it compiles, but it is the bitwise exclusive-or operator, so 7 ^ 2 quietly evaluates to 5 rather than 49. That is a wrong answer with no error message, which makes it worth memorizing now.
Joining strings
The & you have used for concatenation since VB6 is not a C# operator at all.
Option Strict On Imports System Module ConcatenationDemo Sub Main() Dim first As String = "Grace" Dim last As String = "Hopper" Dim joined As String = first & " " & last Dim withNumber As String = "Answer: " & 42 Console.WriteLine(joined) Console.WriteLine(withNumber) End Sub End Module
string first = "Grace"; string last = "Hopper"; string joined = first + " " + last; string withNumber = "Answer: " + 42; Console.WriteLine(joined); Console.WriteLine(withNumber);
C# concatenates with +. Visual Basic has both, and the reason it prefers & is that + is ambiguous there — with Option Strict Off, "1" + 2 could add rather than join. C#'s + has no such ambiguity: if either operand is a string, the other is converted with ToString() and the result is a string. & in C# is bitwise-and, so using it by mistake will not compile against strings.
If() as an expression
Visual Basic overloads one If() function for two different jobs; C# gives each its own operator.
Option Strict On Imports System Module ConditionalDemo Sub Main() Dim score As Integer = 72 Dim grade As String = If(score >= 60, "pass", "fail") Dim supplied As String = Nothing Dim label As String = If(supplied, "(unnamed)") Console.WriteLine($"{grade} / {label}") End Sub End Module
int score = 72; string grade = score >= 60 ? "pass" : "fail"; string? supplied = null; string label = supplied ?? "(unnamed)"; Console.WriteLine($"{grade} / {label}");
Three-argument If(condition, whenTrue, whenFalse) becomes the conditional operator condition ? whenTrue : whenFalse — read the ? as "then" and the : as "otherwise". Two-argument If(value, fallback) becomes ??. Both C# forms short-circuit exactly as the If() function does, which is what made IIf() — the VB6 version that evaluated everything — worth abandoning.
Strings
Building a string in a loop
The same .NET class, reached the same way — the only translation needed is the syntax around it.
Option Strict On Imports System Imports System.Text Module BuilderDemo Sub Main() Dim builder As New StringBuilder() For number As Integer = 1 To 5 builder.Append(number) If number < 5 Then builder.Append(", ") Next Console.WriteLine(builder.ToString()) End Sub End Module
var builder = new StringBuilder(); for (int number = 1; number <= 5; number++) { builder.Append(number); if (number < 5) builder.Append(", "); } Console.WriteLine(builder.ToString());
Dim builder As New StringBuilder() and var builder = new StringBuilder() allocate the identical object. Note that C# has no As New shorthand: new is always an expression on the right of the =. The reason to use StringBuilder is the same in both languages — strings are immutable, so text = text & more inside a loop allocates a fresh string every pass.
Strings that contain quotes and backslashes
This is where a copied Visual Basic string literal is most likely to change meaning, because C# reads the backslash.
Option Strict On Imports System Module QuotingDemo Sub Main() ' A doubled quote escapes a quote Dim quoted As String = "She said ""hello""." ' Backslashes are ordinary characters Dim path As String = "C:\reports\2026\summary.txt" ' A multi-line string needs explicit newlines Dim block As String = "line one" & Environment.NewLine & "line two" Console.WriteLine(quoted) Console.WriteLine(path) Console.WriteLine(block) End Sub End Module
// A backslash escapes, so a quote is \" and a backslash is \\ string quoted = "She said \"hello\"."; // @ turns escaping off — a verbatim string string path = @"C:\reports\2026\summary.txt"; // Three or more quotes open a raw string, newlines and all string block = """ line one line two """; Console.WriteLine(quoted); Console.WriteLine(path); Console.WriteLine(block);
In Visual Basic a string literal has exactly one escape, the doubled quote, and a backslash is just a backslash — which is why Windows paths are so comfortable there. C# treats \ as an escape character, so "C:\reports" either means something else or fails to compile. Two forms rescue you: @"..." switches escaping off, and the raw literal """...""" switches it off and spans lines, stripping the common indentation of the closing delimiter. Both accept a $ prefix for interpolation.
Common string operations
Every method here is the same .NET method — the interesting part is the last line of each column.
Option Strict On Imports System Module StringMethodDemo Sub Main() Dim text As String = " Visual Basic " Console.WriteLine($"[{text.Trim()}]") Console.WriteLine(text.Trim().ToUpper()) Console.WriteLine(text.Contains("Basic")) Console.WriteLine(text.Trim().Replace(" ", "-")) Console.WriteLine(text.Trim().Substring(0, 6)) Console.WriteLine(Mid(text.Trim(), 1, 6)) End Sub End Module
string text = " Visual Basic "; Console.WriteLine($"[{text.Trim()}]"); Console.WriteLine(text.Trim().ToUpper()); Console.WriteLine(text.Contains("Basic")); Console.WriteLine(text.Trim().Replace(" ", "-")); Console.WriteLine(text.Trim().Substring(0, 6)); Console.WriteLine(text.Trim()[..6]);
Because both languages call into System.String, Trim, ToUpper, Contains, Replace and Substring need no translation at all. What has no C# counterpart is the second family Visual Basic keeps for compatibility — Mid, Left, Right, Len, InStr, UCase. Those come from Microsoft.VisualBasic and are 1-based, which is exactly why they are worth dropping: Mid(text, 1, 6) and text.Substring(0, 6) return the same thing from different starting points. C# adds a range operator, [..6], meaning "from the start up to index 6".
Comparing strings
These two columns agree today — and would disagree the moment someone adds one line to the Visual Basic file.
Option Strict On Imports System Module ComparisonDemo Sub Main() Dim left As String = "Report" Dim right As String = "report" Console.WriteLine(left = right) Console.WriteLine(String.Equals(left, right, StringComparison.OrdinalIgnoreCase)) Console.WriteLine(String.Compare(left, right, StringComparison.Ordinal)) End Sub End Module
string left = "Report"; string right = "report"; Console.WriteLine(left == right); Console.WriteLine(string.Equals(left, right, StringComparison.OrdinalIgnoreCase)); Console.WriteLine(string.Compare(left, right, StringComparison.Ordinal));
By default both compare character by character, so "Report" and "report" are different. The difference is that Visual Basic has a file-level switch, Option Compare Text, which silently makes every = on strings in that file case-insensitive. C# has nothing of the kind and never will: == on strings is always an ordinal value comparison. If you rely on Option Compare Text anywhere, that behavior has to be written out explicitly with StringComparison.OrdinalIgnoreCase when you port the code.
Collections
Arrays
Two changes at once here: the brackets, and the number inside them.
Option Strict On Imports System Module ArrayDemo Sub Main() Dim names() As String = {"Ada", "Grace", "Alan"} ' The number in Dim is the UPPER BOUND, not the length Dim scores(4) As Integer scores(0) = 10 scores(4) = 50 Console.WriteLine(names.Length) Console.WriteLine(scores.Length) Console.WriteLine(names(1)) End Sub End Module
string[] names = { "Ada", "Grace", "Alan" }; // The number in new[] is the LENGTH int[] scores = new int[5]; scores[0] = 10; scores[4] = 50; Console.WriteLine(names.Length); Console.WriteLine(scores.Length); Console.WriteLine(names[1]);
Indexing moves from parentheses to square brackets, which also removes a genuine ambiguity — in Visual Basic names(1) could be an array element or a method call, and only the compiler knows which. The number is the real trap: Dim scores(4) declares five slots because it names the highest index, while new int[5] names the count. Both are zero-based and both have .Length, so Dim x(n) becomes new[n + 1]. VB6 programmers should also note that Option Base 1 does not exist in .NET at all.
Lists
The generic type argument changes shape; everything else is the same class with the same methods.
Option Strict On Imports System Imports System.Collections.Generic Module ListDemo Sub Main() Dim fruits As New List(Of String) From {"apple", "banana"} fruits.Add("cherry") fruits.Insert(0, "apricot") fruits.Remove("banana") For Each fruit As String In fruits Console.WriteLine(fruit) Next Console.WriteLine(fruits.Count) End Sub End Module
var fruits = new List<string> { "apple", "banana" }; fruits.Add("cherry"); fruits.Insert(0, "apricot"); fruits.Remove("banana"); foreach (string fruit in fruits) { Console.WriteLine(fruit); } Console.WriteLine(fruits.Count);
List(Of String) becomes List<string> — angle brackets instead of (Of ...), and this holds for every generic type: Dictionary(Of K, V)Dictionary<K, V>, IEnumerable(Of T)IEnumerable<T>. The From { ... } initializer loses the From. If you are used to VB6's Collection object, note it is 1-based and untyped; List<T> is neither.
Dictionaries
The same Dictionary, with C# offering an extra initializer shape and a tidier TryGetValue.
Option Strict On Imports System Imports System.Collections.Generic Module DictionaryDemo Sub Main() Dim ages As New Dictionary(Of String, Integer) From { {"Ada", 36}, {"Grace", 45} } ages("Alan") = 41 For Each entry As KeyValuePair(Of String, Integer) In ages Console.WriteLine($"{entry.Key} is {entry.Value}") Next Dim found As Integer If ages.TryGetValue("Ada", found) Then Console.WriteLine($"Ada: {found}") End If End Sub End Module
var ages = new Dictionary<string, int> { ["Ada"] = 36, ["Grace"] = 45, }; ages["Alan"] = 41; foreach (var entry in ages) { Console.WriteLine($"{entry.Key} is {entry.Value}"); } if (ages.TryGetValue("Ada", out int found)) { Console.WriteLine($"Ada: {found}"); }
Visual Basic's nested-brace initializer {{key, value}, ...} has a C# equivalent, but the indexer form ["Ada"] = 36 shown here reads better and is what most C# code uses. The real convenience is out int found: C# lets you declare the output variable inside the call, so the two-line dance of declaring it first disappears. Note var entry where Visual Basic spelled out KeyValuePair(Of String, Integer) — the type is inferred, not dynamic.
Grids and jagged arrays
Both languages distinguish a rectangular grid from an array of arrays — and both make the declaration syntax harder than it needs to be.
Option Strict On Imports System Module GridDemo Sub Main() Dim grid(,) As Integer = {{1, 2, 3}, {4, 5, 6}} Dim jagged()() As Integer = New Integer(1)() {} jagged(0) = New Integer() {1, 2} jagged(1) = New Integer() {3, 4, 5} Console.WriteLine(grid(1, 2)) Console.WriteLine(jagged(1).Length) End Sub End Module
int[,] grid = { { 1, 2, 3 }, { 4, 5, 6 } }; int[][] jagged = new int[2][]; jagged[0] = new int[] { 1, 2 }; jagged[1] = new int[] { 3, 4, 5 }; Console.WriteLine(grid[1, 2]); Console.WriteLine(jagged[1].Length);
A rectangular array is Dim grid(,) As Integer in one language and int[,] grid in the other; both index with a single pair of brackets holding two numbers. A jagged array — rows of independent length — is ()() and [][], indexed one bracket at a time. The C# form reads left to right (int[][] is "array of int arrays"), whereas the Visual Basic form puts the element type after the parentheses, which is why it looks backwards at first.
Control Flow
Select Case becomes switch
The shape survives, but C# makes you say where each branch ends.
Option Strict On Imports System Module SelectDemo Sub Main() Dim code As Integer = 3 Select Case code Case 1 Console.WriteLine("one") Case 2, 3 Console.WriteLine("two or three") Case 4 To 6 Console.WriteLine("four to six") Case Is > 100 Console.WriteLine("large") Case Else Console.WriteLine("something else") End Select End Sub End Module
int code = 3; switch (code) { case 1: Console.WriteLine("one"); break; case 2: case 3: Console.WriteLine("two or three"); break; case >= 4 and <= 6: Console.WriteLine("four to six"); break; case > 100: Console.WriteLine("large"); break; default: Console.WriteLine("something else"); break; }
Every C# case body must end with break (or return, or goto case) — C# forbids silently falling through to the next branch, so the compiler rejects a missing break rather than doing what Visual Basic already does automatically. Sharing one body between two values is done by stacking bare labels, which is the one legal kind of fall-through. Case Is > 100 becomes a relational pattern case > 100, and Case 4 To 6 becomes case >= 4 and <= 6.
A switch that produces a value
C# has a second form of switch that is an expression, so it can sit on the right of an assignment.
Option Strict On Imports System Module SwitchValueDemo Function Describe(code As Integer) As String Select Case code Case 1 : Return "one" Case 2, 3 : Return "a couple" Case Else : Return "many" End Select End Function Sub Main() Console.WriteLine(Describe(1)) Console.WriteLine(Describe(3)) Console.WriteLine(Describe(9)) End Sub End Module
Console.WriteLine(Describe(1)); Console.WriteLine(Describe(3)); Console.WriteLine(Describe(9)); string Describe(int code) => code switch { 1 => "one", 2 or 3 => "a couple", _ => "many", };
The switch expression puts the value first and the keyword second (code switch), replaces case/:/break with => and a comma, and uses _ for the default arm. Because it is an expression it always produces a value, and the compiler warns when the arms do not cover every possibility. Visual Basic has no equivalent — a Select Case is always a statement, which is why the anchor column needs a whole Function to do the same job.
Conditions must be Boolean
If you keep Option Strict On, this rule already applies to you — and C# applies it whether you like it or not.
Option Strict On Imports System Module ConditionDemo Sub Main() Dim count As Integer = 0 Dim name As String = Nothing ' Option Strict On already forbids If(count), ' so both languages want the comparison spelled out If count = 0 Then Console.WriteLine("empty") If name Is Nothing Then Console.WriteLine("no name") If String.IsNullOrEmpty(name) Then Console.WriteLine("blank") End Sub End Module
int count = 0; string? name = null; if (count == 0) Console.WriteLine("empty"); if (name is null) Console.WriteLine("no name"); if (string.IsNullOrEmpty(name)) Console.WriteLine("blank");
A C# condition must be a bool. There is no implicit "zero is false, non-zero is true" and no implicit "empty string is false"; if (count) does not compile. The habit worth carrying over is spelling out what you actually mean — count == 0, name is null, string.IsNullOrEmpty(name). Note is null rather than == null: both work, but is cannot be redefined by an overloaded operator, so it is the safer of the two.
Loops
For ... Next
The most-changed construct on the page: a declarative range becomes three explicit clauses.
Option Strict On Imports System Module ForDemo Sub Main() For index As Integer = 1 To 5 Console.Write(index & " ") Next Console.WriteLine() For countdown As Integer = 10 To 0 Step -2 Console.Write(countdown & " ") Next Console.WriteLine() End Sub End Module
for (int index = 1; index <= 5; index++) { Console.Write(index + " "); } Console.WriteLine(); for (int countdown = 10; countdown >= 0; countdown -= 2) { Console.Write(countdown + " "); } Console.WriteLine();
C#'s for is "initializer; condition; step", separated by semicolons — you state the ending condition rather than an ending value, which is why the inclusive To 5 becomes <= 5. Getting < and <= the wrong way round is the classic off-by-one here, and it is worth pausing on every time. Step -2 becomes the step clause countdown -= 2, and the loop variable is scoped to the loop exactly as index As Integer is.
For Each
The straightforward half is one word; the second half shows the C# idiom for "I also want the position".
Option Strict On Imports System Imports System.Collections.Generic Module ForEachDemo Sub Main() Dim words As New List(Of String) From {"alpha", "beta", "gamma"} For Each word As String In words Console.WriteLine(word.ToUpper()) Next For index As Integer = 0 To words.Count - 1 Console.WriteLine($"{index}: {words(index)}") Next End Sub End Module
var words = new List<string> { "alpha", "beta", "gamma" }; foreach (string word in words) { Console.WriteLine(word.ToUpper()); } foreach ((string word, int index) in words.Select((value, position) => (value, position))) { Console.WriteLine($"{index}: {word}"); }
For Each x As T In items ... Next becomes foreach (T x in items) { ... } — one word, no Next. Neither language has a built-in index in its for-each, but C# can pair value and position with Select((value, position) => (value, position)) and unpack both in the loop header. The plain counted loop shown in the anchor column is still perfectly good C# too; this is an option, not a replacement.
While and Do loops
Visual Basic offers four spellings of the bottom-tested loop; C# offers one, and it only tests for truth.
Option Strict On Imports System Module WhileDemo Sub Main() Dim remaining As Integer = 3 While remaining > 0 Console.WriteLine($"remaining {remaining}") remaining -= 1 End While Dim attempt As Integer = 0 Do attempt += 1 Console.WriteLine($"attempt {attempt}") Loop Until attempt >= 2 End Sub End Module
int remaining = 3; while (remaining > 0) { Console.WriteLine($"remaining {remaining}"); remaining -= 1; } int attempt = 0; do { attempt += 1; Console.WriteLine($"attempt {attempt}"); } while (attempt < 2);
While ... End While becomes while (...) { }. The Do ... Loop family collapses: Do While c, Do Until c, Loop While c and Loop Until c all become either while or do ... while, and since C# has no Until, the condition has to be inverted — Loop Until attempt >= 2 becomes while (attempt < 2). Note the semicolon after the closing while of a do loop; it is the one place a brace is followed by one.
Leaving a loop early
Two keywords that lose the word naming which construct they are leaving.
Option Strict On Imports System Module ExitDemo Sub Main() For number As Integer = 1 To 10 If number Mod 2 = 0 Then Continue For If number > 7 Then Exit For Console.WriteLine(number) Next End Sub End Module
for (int number = 1; number <= 10; number++) { if (number % 2 == 0) continue; if (number > 7) break; Console.WriteLine(number); }
Exit For, Exit While and Exit Do all become plain break; Continue For and friends become plain continue. Both always act on the innermost enclosing loop, which is a real loss of clarity in nested loops — Visual Basic at least named the construct. C# has goto with a label for the rare case where you need to leave two levels at once, though extracting the loop into a method and using return is the usual answer. Exit Sub and Exit Function both become return.
Methods
Sub and Function both become methods
The Sub/Function distinction disappears — a method that returns nothing simply returns void.
Option Strict On Imports System Module MethodDemo Sub Announce(message As String) Console.WriteLine($"** {message} **") End Sub Function Add(left As Integer, right As Integer) As Integer Return left + right End Function Sub Main() Announce("starting") Console.WriteLine(Add(2, 3)) End Sub End Module
Announce("starting"); Console.WriteLine(Add(2, 3)); void Announce(string message) { Console.WriteLine($"** {message} **"); } int Add(int left, int right) { return left + right; }
The return type moves to the front, replacing both keywords: Sub becomes void and Function ... As Integer becomes int. Return becomes return and is required — C# has nothing like assigning to the function's own name, the VB6 style that Function Add ... Add = 5 allowed. Calling is simpler too: parentheses are always required and always allowed, so the old rule about omitting them when calling a Sub is gone.
ByRef, ref and out
C# splits ByRef into two keywords, and makes the caller say which one it agreed to.
Option Strict On Imports System Module ReferenceDemo Sub Double_(ByRef value As Integer) value *= 2 End Sub Function TryHalve(input As Integer, ByRef result As Integer) As Boolean If input Mod 2 <> 0 Then Return False result = input \ 2 Return True End Function Sub Main() Dim number As Integer = 21 Double_(number) Console.WriteLine(number) Dim half As Integer If TryHalve(10, half) Then Console.WriteLine(half) End Sub End Module
int number = 21; Double_(ref number); Console.WriteLine(number); if (TryHalve(10, out int half)) Console.WriteLine(half); void Double_(ref int value) { value *= 2; } bool TryHalve(int input, out int result) { result = 0; if (input % 2 != 0) return false; result = input / 2; return true; }
ByRef becomes ref when the value goes in and comes back, and out when it is purely an output — and out obliges the method to assign it on every path, which is why TryHalve sets result = 0 first. The bigger change is at the call site: C# requires ref or out on the argument too, so you can never be surprised by a method that reassigns your variable. ByVal has no keyword because it is the only default.
Optional and named arguments
Both features exist in both languages; only two characters change.
Option Strict On Imports System Module ArgumentDemo Function Greet(name As String, Optional greeting As String = "Hello", Optional punctuation As String = "!") As String Return $"{greeting}, {name}{punctuation}" End Function Sub Main() Console.WriteLine(Greet("Ada")) Console.WriteLine(Greet("Grace", "Welcome")) Console.WriteLine(Greet("Alan", punctuation:=".")) End Sub End Module
Console.WriteLine(Greet("Ada")); Console.WriteLine(Greet("Grace", "Welcome")); Console.WriteLine(Greet("Alan", punctuation: ".")); string Greet(string name, string greeting = "Hello", string punctuation = "!") => $"{greeting}, {name}{punctuation}";
The Optional keyword disappears — a default value is enough to make a parameter optional, and optional parameters must still come last. A named argument uses name: with a space rather than name:=. Note the method body here: => introduces an expression-bodied member, a one-line method with no braces and no return, which reads much like Visual Basic's single-line Function shorthand.
A variable number of arguments
A direct rename, with the same rules on both sides.
Option Strict On Imports System Module ParamArrayDemo Function SumAll(ParamArray values() As Integer) As Integer Dim total As Integer = 0 For Each value As Integer In values total += value Next Return total End Function Sub Main() Console.WriteLine(SumAll(1, 2, 3)) Console.WriteLine(SumAll()) Console.WriteLine(SumAll(New Integer() {4, 5})) End Sub End Module
Console.WriteLine(SumAll(1, 2, 3)); Console.WriteLine(SumAll()); Console.WriteLine(SumAll(new int[] { 4, 5 })); int SumAll(params int[] values) { int total = 0; foreach (int value in values) { total += value; } return total; }
ParamArray values() As Integer becomes params int[] values. In both languages it must be the last parameter, there can be only one, and the caller may pass either loose arguments or a ready-made array. This is what Console.WriteLine's many overloads and String.Format have always used underneath.
Classes & Objects
A class and its constructor
The constructor loses its special name and takes the name of the class instead.
Option Strict On Imports System Public Class Person Private ReadOnly _name As String Private ReadOnly _age As Integer Public Sub New(name As String, age As Integer) _name = name _age = age End Sub Public Function Describe() As String Return $"{_name}, age {_age}" End Function End Class Module ClassDemo Sub Main() Dim person As New Person("Ada", 36) Console.WriteLine(person.Describe()) End Sub End Module
var person = new Person("Ada", 36); Console.WriteLine(person.Describe()); class Person { private readonly string _name; private readonly int _age; public Person(string name, int age) { _name = name; _age = age; } public string Describe() => $"{_name}, age {_age}"; }
Public Sub New(...) becomes a method with no return type whose name is exactly the class name. Me becomes this where you need it. Notice the C# class sits below the executable statements: in a file using top-level statements, type declarations must come after them. Access modifiers are the same words in lower case, and a C# class member with no modifier at all is private, whereas an unmarked Visual Basic member is Public — so leaving the keyword off changes the meaning.
Properties
Properties are a Visual Basic invention that C# adopted, so the concept transfers whole — only the punctuation moves.
Option Strict On Imports System Public Class Temperature ' Auto-implemented property Public Property Label As String = "reading" Private _celsius As Double Public Property Celsius As Double Get Return _celsius End Get Set(value As Double) _celsius = Math.Max(value, -273.15) End Set End Property Public ReadOnly Property Fahrenheit As Double Get Return _celsius * 9.0 / 5.0 + 32 End Get End Property End Class Module PropertyDemo Sub Main() Dim reading As New Temperature With {.Celsius = 100.0} Console.WriteLine($"{reading.Label}: {reading.Fahrenheit}") reading.Celsius = -500 Console.WriteLine(reading.Celsius) End Sub End Module
var reading = new Temperature { Celsius = 100.0 }; Console.WriteLine($"{reading.Label}: {reading.Fahrenheit}"); reading.Celsius = -500; Console.WriteLine(reading.Celsius); class Temperature { // Auto-implemented property public string Label { get; set; } = "reading"; private double _celsius; public double Celsius { get => _celsius; set => _celsius = Math.Max(value, -273.15); } public double Fahrenheit => _celsius * 9.0 / 5.0 + 32; }
Public Property Label As String becomes public string Label { get; set; }, and the full Get/Set blocks become get and set accessors, which can be one-liners with =>. The implicit value parameter exists in both. A ReadOnly Property with only a Get collapses all the way to a single expression-bodied member, as Fahrenheit shows. The object initializer With {.Celsius = 100.0} becomes { Celsius = 100.0 } — no With, no leading dots.
Shared becomes static
One keyword swap — and the disappearance of the construct you have been using instead.
Option Strict On Imports System Public Class Counter Private Shared _total As Integer = 0 Public Shared Sub Increment() _total += 1 End Sub Public Shared ReadOnly Property Total As Integer Get Return _total End Get End Property End Class Module SharedDemo Sub Main() Counter.Increment() Counter.Increment() Console.WriteLine(Counter.Total) End Sub End Module
Counter.Increment(); Counter.Increment(); Console.WriteLine(Counter.Total); class Counter { private static int _total = 0; public static void Increment() => _total += 1; public static int Total => _total; }
Shared becomes static, with the same meaning: one copy on the type rather than one per instance. What has no direct counterpart is the Module itself, which is really a class where everything is Shared and whose members can be called without naming it. The C# equivalent is a static class, but its members always need the type name in front — Counter.Total, never a bare Total. A using static Counter; directive restores the bare form when you want it.
Inheritance and overriding
Four Visual Basic keywords are replaced here, and one of them is a colon.
Option Strict On Imports System Public Class Animal Protected ReadOnly Name As String Public Sub New(name As String) Me.Name = name End Sub Public Overridable Function Speak() As String Return $"{Name} makes a sound" End Function End Class Public Class Dog Inherits Animal Public Sub New(name As String) MyBase.New(name) End Sub Public Overrides Function Speak() As String Return $"{Name} barks" End Function End Class Module InheritanceDemo Sub Main() Dim pets As Animal() = {New Animal("Thing"), New Dog("Rex")} For Each pet As Animal In pets Console.WriteLine(pet.Speak()) Next End Sub End Module
Animal[] pets = { new Animal("Thing"), new Dog("Rex") }; foreach (Animal pet in pets) { Console.WriteLine(pet.Speak()); } class Animal { protected readonly string Name; public Animal(string name) => Name = name; public virtual string Speak() => $"{Name} makes a sound"; } class Dog : Animal { public Dog(string name) : base(name) { } public override string Speak() => $"{Name} barks"; }
Inherits Animal becomes : Animal on the class header — the same colon C# uses for interfaces, so a class list reads "base class first, then interfaces". Overridable becomes virtual, Overrides becomes override, and MyBase becomes base. Calling the base constructor moves from a statement inside the body (MyBase.New(name)) to a clause on the header (: base(name)). Both languages agree on the important part: a method is not overridable unless it says so.
MustInherit and NotInheritable
Two of the longest keywords in Visual Basic become two of the shortest in C#.
Option Strict On Imports System Public MustInherit Class Shape Public MustOverride Function Area() As Double Public Function Report() As String Return $"{Me.GetType().Name}: {Area():F2}" End Function End Class Public NotInheritable Class Circle Inherits Shape Private ReadOnly _radius As Double Public Sub New(radius As Double) _radius = radius End Sub Public Overrides Function Area() As Double Return Math.PI * _radius * _radius End Function End Class Module AbstractDemo Sub Main() Dim shape As Shape = New Circle(2.0) Console.WriteLine(shape.Report()) End Sub End Module
Shape shape = new Circle(2.0); Console.WriteLine(shape.Report()); abstract class Shape { public abstract double Area(); public string Report() => $"{GetType().Name}: {Area():F2}"; } sealed class Circle : Shape { private readonly double _radius; public Circle(double radius) => _radius = radius; public override double Area() => Math.PI * _radius * _radius; }
MustInherit becomes abstract on the class, MustOverride becomes abstract on the member, and NotInheritable becomes sealed. NotOverridable — sealing a single override so no further subclass can change it — is also sealed, written as sealed override. C# reuses one word where Visual Basic used four, which is terser but means you read the position to know which meaning applies.
Interfaces
This is one place where Visual Basic is genuinely more flexible than C#, and the flexibility does not survive the trip.
Option Strict On Imports System Public Interface IGreeter Function Greet(name As String) As String End Interface Public Class Formal Implements IGreeter ' Each member names the interface member it satisfies Public Function SayHello(name As String) As String Implements IGreeter.Greet Return $"Good day, {name}." End Function End Class Module InterfaceDemo Sub Main() Dim greeter As IGreeter = New Formal() Console.WriteLine(greeter.Greet("Ada")) End Sub End Module
IGreeter greeter = new Formal(); Console.WriteLine(greeter.Greet("Ada")); interface IGreeter { string Greet(string name); } class Formal : IGreeter { // The name must match the interface member public string Greet(string name) => $"Good day, {name}."; }
Visual Basic attaches Implements IGreeter.Greet to each member individually, so the method may be called anything you like — SayHello here satisfies Greet. C# matches by signature: a public method with the right name and parameters implements the interface member automatically, and there is no clause to say otherwise. When two interfaces demand the same signature and you need different bodies, C# offers explicit implementation — string IGreeter.Greet(string name) — which is the closest equivalent, and such a member is callable only through the interface.
Structures, Records & Tuples
Structure becomes struct
Both languages have a value type that copies on assignment; the keyword shortens and gains a useful modifier.
Option Strict On Imports System 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 Public Overrides Function ToString() As String Return $"({X}, {Y})" End Function End Structure Module StructureDemo Sub Main() Dim origin As New Point(0, 0) Dim copy As Point = origin Console.WriteLine($"{origin} {copy}") Console.WriteLine(origin.Equals(copy)) End Sub End Module
var origin = new Point(0, 0); Point copy = origin; Console.WriteLine($"{origin} {copy}"); Console.WriteLine(origin.Equals(copy)); readonly struct Point { public readonly int X; public readonly int Y; public Point(int x, int y) { X = x; Y = y; } public override string ToString() => $"({X}, {Y})"; }
Structure ... End Structure becomes struct { }, with the same semantics — assignment copies the whole value rather than sharing a reference, and equality compares fields. C# adds readonly struct, which promises the compiler that no member mutates the value and lets it skip defensive copies. Overrides becomes override here as with any inherited method, and Me.X becomes plain X where there is no name collision.
Records — a class in one line
The whole anchor column is what a C# record writes for you from a single line.
Option Strict On Imports System Public Class Money Public ReadOnly Property Amount As Decimal Public ReadOnly Property Currency As String Public Sub New(amount As Decimal, currency As String) Me.Amount = amount Me.Currency = currency End Sub Public Overrides Function Equals(other As Object) As Boolean Dim candidate = TryCast(other, Money) Return candidate IsNot Nothing AndAlso candidate.Amount = Amount AndAlso candidate.Currency = Currency End Function Public Overrides Function GetHashCode() As Integer Return HashCode.Combine(Amount, Currency) End Function Public Overrides Function ToString() As String Return $"Money {{ Amount = {Amount}, Currency = {Currency} }}" End Function End Class Module RecordDemo Sub Main() Dim price As New Money(9.99D, "USD") Dim same As New Money(9.99D, "USD") Console.WriteLine(price) Console.WriteLine(price.Equals(same)) End Sub End Module
var price = new Money(9.99m, "USD"); var same = new Money(9.99m, "USD"); Console.WriteLine(price); Console.WriteLine(price == same); var cheaper = price with { Amount = 4.99m }; Console.WriteLine(cheaper); record Money(decimal Amount, string Currency);
A record declares the properties, the constructor, value-based Equals, GetHashCode, ==, and a readable ToString in one line. It also gets a with expression, which copies the value and changes named parts — an idea Visual Basic has no equivalent for, and unrelated to the With block. Visual Basic cannot declare a record; when you need this shape there, you write it out, which is what the anchor column shows. This is one of the clearest reasons a Visual Basic codebase gains from moving.
Tuples and returning two things
Named tuples work the same way in both languages — what C# adds is a way to unpack one in a single line.
Option Strict On Imports System Module TupleDemo Function MinimumAndMaximum(values() As Integer) As (Smallest As Integer, Largest As Integer) Dim smallest As Integer = values(0) Dim largest As Integer = values(0) For Each value As Integer In values If value < smallest Then smallest = value If value > largest Then largest = value Next Return (smallest, largest) End Function Sub Main() Dim result = MinimumAndMaximum(New Integer() {4, 9, 1, 7}) Console.WriteLine($"{result.Smallest}..{result.Largest}") Dim low As Integer, high As Integer Dim pair = MinimumAndMaximum(New Integer() {3, 8}) low = pair.Smallest high = pair.Largest Console.WriteLine($"{low}..{high}") End Sub End Module
var result = MinimumAndMaximum(new[] { 4, 9, 1, 7 }); Console.WriteLine($"{result.Smallest}..{result.Largest}"); var (low, high) = MinimumAndMaximum(new[] { 3, 8 }); Console.WriteLine($"{low}..{high}"); (int Smallest, int Largest) MinimumAndMaximum(int[] values) { int smallest = values[0]; int largest = values[0]; foreach (int value in values) { if (value < smallest) smallest = value; if (value > largest) largest = value; } return (smallest, largest); }
The tuple type (Smallest As Integer, Largest As Integer) becomes (int Smallest, int Largest), and both let you name the parts so the caller does not have to remember an order. Deconstruction is C#-only: var (low, high) = ... pulls the parts into separate variables in one statement, which is what makes returning a tuple pleasant rather than merely possible. Visual Basic has to name the tuple and read its fields, as the anchor column does.
Pattern Matching
Testing a type and using it
The two-step "test the type, then convert it" collapses into one step that also declares the variable.
Option Strict On Imports System Module TypePatternDemo Sub Describe(value As Object) If TypeOf value Is Integer Then Dim number = CInt(value) Console.WriteLine($"integer {number * 2}") ElseIf TypeOf value Is String Then Dim text = CStr(value) Console.WriteLine($"string of {text.Length}") Else Console.WriteLine("something else") End If End Sub Sub Main() Describe(21) Describe("hello") Describe(3.5) End Sub End Module
Describe(21); Describe("hello"); Describe(3.5); void Describe(object value) { if (value is int number) { Console.WriteLine($"integer {number * 2}"); } else if (value is string text) { Console.WriteLine($"string of {text.Length}"); } else { Console.WriteLine("something else"); } }
TypeOf value Is Integer becomes value is int, and adding a name — value is int number — both tests and converts in one go, with number in scope for the rest of the branch. That name is why the cast on the next line vanishes. Note that C# is is a type test, whereas Visual Basic's bare Is compares references; the gotchas section returns to this, because it is the one keyword whose meaning genuinely changes.
Matching shapes, not just values
The nested ladder in the anchor column is what a C# switch expression flattens into a table.
Option Strict On Imports System Module ShapePatternDemo Function Classify(value As Object) As String If TypeOf value Is Integer Then Dim number = CInt(value) If number < 0 Then Return "negative" If number = 0 Then Return "zero" Return "positive" End If If TypeOf value Is String Then Dim text = CStr(value) If text.Length = 0 Then Return "empty text" Return "text" End If If value Is Nothing Then Return "nothing" Return "unknown" End Function Sub Main() Console.WriteLine(Classify(-4)) Console.WriteLine(Classify(0)) Console.WriteLine(Classify("")) Console.WriteLine(Classify(Nothing)) End Sub End Module
Console.WriteLine(Classify(-4)); Console.WriteLine(Classify(0)); Console.WriteLine(Classify("")); Console.WriteLine(Classify(null)); string Classify(object? value) => value switch { int and < 0 => "negative", 0 => "zero", int => "positive", string { Length: 0 } => "empty text", string => "text", null => "nothing", _ => "unknown", };
Patterns compose: int and < 0 combines a type pattern with a relational one, and string { Length: 0 } is a property pattern that matches a string whose Length is zero. Arms are tried top to bottom, so the narrow cases come first. Visual Basic has no pattern matching at all — its Select Case compares values only — so any code shaped like this is a candidate for real simplification when it moves.
Matching on an object's parts
A decision table written as conditions, and the same table written as patterns.
Option Strict On Imports System Public Class Order Public Property Total As Decimal Public Property Country As String = "" End Class Module PropertyPatternDemo Function Shipping(order As Order) As Decimal If order.Country = "US" AndAlso order.Total > 100D Then Return 0D If order.Country = "US" Then Return 5D If order.Total > 100D Then Return 15D Return 25D End Function Sub Main() Console.WriteLine(Shipping(New Order With {.Total = 150D, .Country = "US"})) Console.WriteLine(Shipping(New Order With {.Total = 50D, .Country = "US"})) Console.WriteLine(Shipping(New Order With {.Total = 150D, .Country = "FR"})) Console.WriteLine(Shipping(New Order With {.Total = 50D, .Country = "FR"})) End Sub End Module
Console.WriteLine(Shipping(new Order { Total = 150m, Country = "US" })); Console.WriteLine(Shipping(new Order { Total = 50m, Country = "US" })); Console.WriteLine(Shipping(new Order { Total = 150m, Country = "FR" })); Console.WriteLine(Shipping(new Order { Total = 50m, Country = "FR" })); decimal Shipping(Order order) => order switch { { Country: "US", Total: > 100 } => 0m, { Country: "US" } => 5m, { Total: > 100 } => 15m, _ => 25m, }; class Order { public decimal Total { get; set; } public string Country { get; set; } = ""; }
A property pattern names members inside braces — { Country: "US", Total: > 100 } — and each part may itself be any pattern, including another property pattern for a nested object. Compared with the chain of If statements it replaces, the win is that the conditions line up in a column, so a missing combination is visible rather than buried. The compiler also warns if an arm is unreachable because an earlier one already covers it.
Error Handling
Try, Catch, Finally
Structured error handling is the part of Visual Basic that needs the least translation.
Option Strict On Imports System Module TryDemo Sub Main() Try Dim numbers() As Integer = {1, 2, 3} Console.WriteLine(numbers(10)) Catch error_ As IndexOutOfRangeException Console.WriteLine($"Out of range: {error_.Message}") Catch error_ As Exception Console.WriteLine($"Something else: {error_.Message}") Finally Console.WriteLine("always runs") End Try End Sub End Module
try { int[] numbers = { 1, 2, 3 }; Console.WriteLine(numbers[10]); } catch (IndexOutOfRangeException error) { Console.WriteLine($"Out of range: {error.Message}"); } catch (Exception error) { Console.WriteLine($"Something else: {error.Message}"); } finally { Console.WriteLine("always runs"); }
Catch name As TypeName becomes catch (TypeName name) — the same reordering as a variable declaration. Order still matters: the most specific exception type first, or the broad Exception catch swallows everything below it. Finally runs on every path in both. What does not come across is On Error Resume Next and On Error GoTo: C# has no unstructured error handling at all, and VB6 or VBA code built on it has to be rewritten as try blocks rather than translated.
Catching only some of them
Another feature Visual Basic had first and C# borrowed — the keyword is even the same.
Option Strict On Imports System Module FilterDemo Sub Attempt(code As Integer) Try Throw New InvalidOperationException($"failure {code}") Catch error_ As InvalidOperationException When code = 1 Console.WriteLine("handled the code-1 case") Catch error_ As InvalidOperationException Console.WriteLine($"passed through: {error_.Message}") End Try End Sub Sub Main() Attempt(1) Attempt(2) End Sub End Module
Attempt(1); Attempt(2); void Attempt(int code) { try { throw new InvalidOperationException($"failure {code}"); } catch (InvalidOperationException) when (code == 1) { Console.WriteLine("handled the code-1 case"); } catch (InvalidOperationException error) { Console.WriteLine($"passed through: {error.Message}"); } }
When becomes when, and the condition gains parentheses. The point of a filter rather than an if inside the catch is that a filter runs before the stack unwinds: if it returns false the exception continues to propagate with its original stack intact, which is a real advantage when debugging. Note that C# lets you omit the variable name entirely when you do not need it, as the first catch does.
Throwing your own exception
Defining an exception type is the inheritance rules of the previous section applied to one specific base class.
Option Strict On Imports System Public Class InsufficientFundsException Inherits Exception Public ReadOnly Property Shortfall As Decimal Public Sub New(shortfall As Decimal) MyBase.New($"Short by {shortfall:C}") Me.Shortfall = shortfall End Sub End Class Module ThrowDemo Sub Withdraw(balance As Decimal, amount As Decimal) If amount > balance Then Throw New InsufficientFundsException(amount - balance) End If Console.WriteLine($"Withdrew {amount:C}") End Sub Sub Main() Try Withdraw(50D, 75D) Catch error_ As InsufficientFundsException Console.WriteLine($"{error_.Message} (short {error_.Shortfall})") End Try End Sub End Module
try { Withdraw(50m, 75m); } catch (InsufficientFundsException error) { Console.WriteLine($"{error.Message} (short {error.Shortfall})"); } void Withdraw(decimal balance, decimal amount) { if (amount > balance) { throw new InsufficientFundsException(amount - balance); } Console.WriteLine($"Withdrew {amount:C}"); } class InsufficientFundsException : Exception { public decimal Shortfall { get; } public InsufficientFundsException(decimal shortfall) : base($"Short by {shortfall:C}") => Shortfall = shortfall; }
Throw New becomes throw new, and the custom type inherits from Exception with : Exception. Public ReadOnly Property Shortfall with no setter becomes public decimal Shortfall { get; } — a get-only auto-property, settable only from the constructor. Both languages let a bare Throw / throw inside a catch rethrow the current exception without resetting its stack trace, which is what you want when you are logging and passing it on.
Releasing a resource
The same construct, plus a C# form that removes a level of indentation.
Option Strict On Imports System Imports System.IO Module UsingDemo Sub Main() Dim tempFile As String = Path.Combine(Path.GetTempPath(), "visualbasic-csharp-using.txt") Using writer As New StreamWriter(tempFile) writer.WriteLine("first line") writer.WriteLine("second line") End Using Using reader As New StreamReader(tempFile) Console.WriteLine(reader.ReadToEnd().Trim()) End Using File.Delete(tempFile) End Sub End Module
string path = Path.Combine(Path.GetTempPath(), "visualbasic-csharp-using-target.txt"); using (var writer = new StreamWriter(path)) { writer.WriteLine("first line"); writer.WriteLine("second line"); } using var reader = new StreamReader(path); Console.WriteLine(reader.ReadToEnd().Trim()); reader.Dispose(); File.Delete(path);
Using ... End Using becomes using (...) { }, and both guarantee Dispose is called even when an exception escapes. C# adds the using declarationusing var reader = ... with no block at all — which disposes at the end of the enclosing scope instead, and is the form most new C# code uses when a method holds one resource for its whole body. The explicit Dispose() here is only so the file can be deleted on the next line.
Lambdas & LINQ
Lambda expressions
One arrow replaces both Function(...) and Sub(...), and the closing keyword goes away.
Option Strict On Imports System Imports System.Collections.Generic Module LambdaDemo Sub Main() Dim double_ As Func(Of Integer, Integer) = Function(value) value * 2 Dim shout As Action(Of String) = Sub(message) Console.WriteLine(message.ToUpper()) End Sub Console.WriteLine(double_(21)) shout("done") End Sub End Module
Func<int, int> double_ = value => value * 2; Action<string> shout = message => { Console.WriteLine(message.ToUpper()); }; Console.WriteLine(double_(21)); shout("done");
Function(value) value * 2 becomes value => value * 2; a multi-statement lambda uses braces instead of Sub ... End Sub. C# does not distinguish the two forms — whether a lambda returns a value is decided by its body and the delegate type it is assigned to. Func(Of Integer, Integer) becomes Func<int, int> and Action(Of String) becomes Action<string>; in both languages the last type argument of Func is the return type, and Action returns nothing.
LINQ with methods
Identical methods on identical types — the only differences are the lambda arrow and where the dot goes.
Option Strict On Imports System Imports System.Linq Module LinqMethodDemo Sub Main() Dim numbers() As Integer = {5, 3, 9, 1, 7, 2} Dim result = numbers. Where(Function(number) number > 2). OrderBy(Function(number) number). Select(Function(number) number * 10). ToList() Console.WriteLine(String.Join(", ", result)) Console.WriteLine(numbers.Sum()) Console.WriteLine(numbers.Average()) End Sub End Module
int[] numbers = { 5, 3, 9, 1, 7, 2 }; var result = numbers .Where(number => number > 2) .OrderBy(number => number) .Select(number => number * 10) .ToList(); Console.WriteLine(string.Join(", ", result)); Console.WriteLine(numbers.Sum()); Console.WriteLine(numbers.Average());
Once the lambdas are converted, LINQ method chains translate mechanically, because they are the same extension methods on IEnumerable<T>. One small formatting note: Visual Basic puts the dot at the end of the line to continue the chain, C# puts it at the start of the next line. Both are conventions, but every C# codebase you meet will use the leading dot.
LINQ query syntax
The one place where a Visual Basic programmer is likely to find C# slightly poorer.
Option Strict On Imports System Imports System.Linq Module LinqQueryDemo Sub Main() Dim words() As String = {"delta", "alpha", "charlie", "bravo"} Dim query = From word In words Where word.Length > 5 Order By word Select word.ToUpper() For Each item As String In query Console.WriteLine(item) Next End Sub End Module
string[] words = { "delta", "alpha", "charlie", "bravo" }; var query = from word in words where word.Length > 5 orderby word select word.ToUpper(); foreach (string item in query) { Console.WriteLine(item); }
The keywords lower-case and Order By becomes one word, orderby. C#'s query syntax is the smaller of the two: Visual Basic also has Aggregate, Distinct, Skip, Take and Group By ... Into as query keywords, where C# has only from, where, select, orderby, join, let, group ... by and into. Anything else is written as a method call on the end of the query, which is why most C# code uses method syntax throughout rather than mixing the two.
Grouping and aggregating
Grouping is where LINQ earns its keep, and it needs no translation beyond the lambda arrow.
Option Strict On Imports System Imports System.Linq Module GroupingDemo Sub Main() Dim words() As String = {"apple", "avocado", "banana", "blueberry", "cherry"} Dim groups = words. GroupBy(Function(word) word(0)). OrderBy(Function(group) group.Key) For Each group In groups Console.WriteLine($"{group.Key}: {group.Count()} ({String.Join(", ", group)})") Next End Sub End Module
string[] words = { "apple", "avocado", "banana", "blueberry", "cherry" }; var groups = words .GroupBy(word => word[0]) .OrderBy(group => group.Key); foreach (var group in groups) { Console.WriteLine($"{group.Key}: {group.Count()} ({string.Join(", ", group)})"); }
GroupBy returns a sequence of groups, each of which has a Key and is itself a sequence you can iterate or aggregate. The only Visual Basic-specific detail that changes is indexing a string: word(0) becomes word[0], because square brackets index and parentheses call. Every aggregate you know — Count, Sum, Average, Min, Max, Aggregate — is the same method in both.
Modern C#
Async and await
Another feature both languages shipped together — and one where top-level statements save real ceremony.
Option Strict On Imports System Imports System.Threading.Tasks Module AsyncDemo Async Function FetchAsync(label As String) As Task(Of String) Await Task.Delay(10) Return $"result for {label}" End Function Async Function RunAsync() As Task Dim first As String = Await FetchAsync("one") Dim second As String = Await FetchAsync("two") Console.WriteLine(first) Console.WriteLine(second) End Function Sub Main() RunAsync().GetAwaiter().GetResult() End Sub End Module
string first = await FetchAsync("one"); string second = await FetchAsync("two"); Console.WriteLine(first); Console.WriteLine(second); async Task<string> FetchAsync(string label) { await Task.Delay(10); return $"result for {label}"; }
Async Function ... As Task(Of String) becomes async Task<string>, and Await becomes await. An Async Sub becomes async void and is just as bad an idea in both — its exceptions cannot be caught by the caller. The genuine convenience is that C# allows await directly in top-level statements: the compiler generates an async Main for you, so the GetAwaiter().GetResult() bridge in the anchor column is not needed.
Iterators: Yield
The Iterator keyword disappears, because in C# the yield is what makes the method an iterator.
Option Strict On Imports System Imports System.Collections.Generic Module IteratorDemo Iterator Function Fibonacci(count As Integer) As IEnumerable(Of Integer) Dim previous As Integer = 0 Dim current As Integer = 1 For step_ As Integer = 1 To count Yield previous Dim next_ As Integer = previous + current previous = current current = next_ Next End Function Sub Main() For Each number As Integer In Fibonacci(8) Console.Write(number & " ") Next Console.WriteLine() End Sub End Module
foreach (int number in Fibonacci(8)) { Console.Write(number + " "); } Console.WriteLine(); IEnumerable<int> Fibonacci(int count) { int previous = 0; int current = 1; for (int step = 1; step <= count; step++) { yield return previous; (previous, current) = (current, previous + current); } }
Iterator Function ... As IEnumerable(Of Integer) becomes plain IEnumerable<int> and Yield x becomes yield return x. Both are lazy: nothing runs until the caller asks for the first item, and execution suspends at each yield. Exit Function in an iterator becomes yield break. The C# column also slips in tuple assignment — (previous, current) = (current, previous + current) — which swaps two variables without a temporary, something Visual Basic has no syntax for.
Extension methods
The same feature, marked in a completely different way — an attribute in one language, a parameter modifier in the other.
Option Strict On Imports System Imports System.Runtime.CompilerServices Module StringExtensions <Extension()> Public Function Shout(text As String) As String Return text.ToUpper() & "!" End Function End Module Module ExtensionDemo Sub Main() Console.WriteLine("hello".Shout()) End Sub End Module
Console.WriteLine("hello".Shout()); static class StringExtensions { public static string Shout(this string text) => text.ToUpper() + "!"; }
Visual Basic marks an extension method with the <Extension()> attribute and needs Imports System.Runtime.CompilerServices for it. C# marks it by putting this on the first parameter, inside a static class, with no attribute and no import. Both compile to the same thing — an ordinary static method the compiler lets you call as though it were an instance method. Attributes in general translate by swapping the brackets: <Obsolete("...")> becomes [Obsolete("...")].
Imports becomes using
One keyword change, plus a wrinkle Visual Basic programmers rarely think about because their project file hides it.
Option Strict On Imports System Imports System.Collections.Generic Imports Shortcut = System.Text.StringBuilder Namespace Reporting Public Class Report Public Shared Function Title() As String Return "Quarterly" End Function End Class End Namespace Module NamespaceDemo Sub Main() Console.WriteLine(Reporting.Report.Title()) Dim builder As New Shortcut() builder.Append("built") Console.WriteLine(builder.ToString()) End Sub End Module
using Shortcut = System.Text.StringBuilder; Console.WriteLine(Reporting.Report.Title()); var builder = new Shortcut(); builder.Append("built"); Console.WriteLine(builder.ToString()); namespace Reporting { class Report { public static string Title() => "Quarterly"; } }
Imports becomes using, and an aliased import keeps the same Name = FullType shape. The wrinkle: a Visual Basic project has project-level imports set in the project properties, so System is often already available without a line in the file. C# has the same idea under the name implicit usings, on by default in new projects, which is why the examples on this page never write using System;. Namespaces also gain a file-scoped form — namespace Reporting; on one line, with no braces, indenting nothing.
⚠ Gotchas for Visual Basic Programmers
⚠ Two names that used to be one
Worth stating twice because it is the difference most likely to produce a bug that compiles.
Option Strict On Imports System Module CaseGotcha Sub Main() Dim total As Integer = 10 ' The editor rewrites this to match the declaration Total = Total + 5 Console.WriteLine(total) End Sub End Module
int total = 10; // int Total; would be a SECOND variable, silently total = total + 5; Console.WriteLine(total);
In Visual Basic you may type Total where you declared total and the editor quietly corrects you. In C# nothing corrects you — either the name is undefined and you get an error, or, much worse, a name with that exact casing exists elsewhere and your code compiles while referring to the wrong thing. The habit that removes the risk is the .NET naming convention: locals and parameters camelCase, everything public PascalCase. Follow it and two names never differ by case alone.
⚠ Is does not mean Is
The same three letters in both languages, asking two completely different questions.
Option Strict On Imports System Module IsGotcha Sub Main() Dim left As Object = New Object() Dim right As Object = left Dim other As Object = New Object() ' Is compares REFERENCES Console.WriteLine(left Is right) Console.WriteLine(left Is other) Console.WriteLine(left IsNot other) ' TypeOf ... Is asks about the type Console.WriteLine(TypeOf left Is Object) End Sub End Module
object left = new object(); object right = left; object other = new object(); // ReferenceEquals compares references Console.WriteLine(ReferenceEquals(left, right)); Console.WriteLine(ReferenceEquals(left, other)); Console.WriteLine(!ReferenceEquals(left, other)); // is asks about the TYPE Console.WriteLine(left is object);
Visual Basic's Is compares references and TypeOf x Is T asks about the type. C# reverses it: is is the type test, and reference comparison is ReferenceEquals(a, b) or (object)a == (object)b. So left Is right translated literally to left is right does not mean the same thing — and against a type name it will happily compile. IsNot has no C# keyword at all; negate with !. The one place is keeps the Visual Basic meaning is x is null, which is a null check in both.
⚠ Dim x(5) is not new int[5]
Six slots in one column, five in the other — from the same number.
Option Strict On Imports System Module BoundsGotcha Sub Main() Dim slots(5) As Integer Console.WriteLine(slots.Length) Console.WriteLine(UBound(slots)) slots(5) = 99 Console.WriteLine(slots(5)) End Sub End Module
int[] slots = new int[5]; Console.WriteLine(slots.Length); Console.WriteLine(slots.Length - 1); slots[4] = 99; Console.WriteLine(slots[4]);
Dim slots(5) declares indices 0 through 5, which is six elements, because the number is the upper bound. new int[5] declares five, because the number is the count. Translating one to the other means adding or subtracting one, every time. UBound(array) has no C# equivalent and becomes array.Length - 1; LBound is always zero and simply disappears. Anyone bringing habits from VB6 should also note that Option Base 1 and ReDim Preserve have no counterparts here at all — resizing means Array.Resize or, far better, a List<T>.
⚠ There is no With block
A construct you have used for twenty years, with no replacement — only workarounds, one of which is genuinely better.
Option Strict On Imports System Imports System.Text Module WithGotcha Sub Main() Dim builder As New StringBuilder() With builder .Append("first") .Append(" / ") .Append("second") End With Console.WriteLine(builder.ToString()) End Sub End Module
var builder = new StringBuilder(); builder.Append("first") .Append(" / ") .Append("second"); Console.WriteLine(builder.ToString());
C# has no With block. Three things replace it depending on why you were using it. For setting several properties on a new object, use an object initializer: new Order { Total = 5m, Country = "US" }. For calling several methods in a row, use method chaining where the type supports it, as StringBuilder does above. Otherwise, assign the object to a short local and repeat the name — which is what the compiler was doing for you anyway. Note that C#'s with keyword is unrelated: it copies a record, as the records section showed.
⚠ The My namespace does not exist
Both columns do the same work with the same .NET calls — the point is what is missing from the other side.
Option Strict On Imports System Imports System.IO Module MyGotcha Sub Main() ' My.Computer.FileSystem.ReadAllText(path) and friends ' are a Visual Basic convenience layer over these APIs Dim tempFile As String = Path.Combine(Path.GetTempPath(), "visualbasic-csharp-my.txt") File.WriteAllText(tempFile, "written") Console.WriteLine(File.ReadAllText(tempFile)) Console.WriteLine(Environment.MachineName.Length > 0) File.Delete(tempFile) End Sub End Module
string path = Path.Combine(Path.GetTempPath(), "visualbasic-csharp-my-target.txt"); File.WriteAllText(path, "written"); Console.WriteLine(File.ReadAllText(path)); Console.WriteLine(Environment.MachineName.Length > 0); File.Delete(path);
The My namespace — My.Computer, My.Application, My.Settings, My.Resources, My.Forms — is generated by the Visual Basic compiler and has no C# counterpart. Everything it offers is a shortcut to something in the base class library, so porting means finding the real API underneath: My.Computer.FileSystem is System.IO.File and Directory, My.Computer.Name is Environment.MachineName, My.Settings is the generated Settings class, My.Application.Info is Assembly.GetEntryAssembly(). The same is true of the Microsoft.VisualBasic functions — MsgBox, InputBox, IsNumeric, Val, Now.