VB.NET on .NET 10: What Works and What C# Breaks

VB.NET on .NET 10, tested: WinForms builds on a Mac, C# features VB can't use (real BC30668, BC37315), a Span crash, and 30% lost to overflow checks.

Series: VB.NET lecture 6/6 (advanced). For teams that own a VB.NET codebase and must decide what to do with it on .NET 10. After this lecture you know, from compiler output rather than rumour, what VB can and cannot consume from modern C#, how to add C# next to VB without a rewrite, and what the one real performance difference costs.

If you are responsible for a VB.NET application, sooner or later someone asks "should we rewrite it in C#?" The usual answers are opinions. This lecture answers with a build log: we wrote a C# 14 class library full of modern features, referenced it from a VB project on .NET 10, and recorded every BC error, every silent success, and one program that compiled cleanly and then crashed before Main printed a line. The last part measures the same loop in both languages.

Environment: .NET SDK 10.0.403 (Roslyn 5.9.0), runtime 10.0.14, Visual Basic 17.13 and C# 14 (SDK defaults), Apple Silicon Mac (M1 Pro). Timings are Release builds after a JIT warm-up.

Where VB stands in 2026

Microsoft's position is written down on Microsoft Learn, in the article ".NET Managed languages strategy". In short, it says that Visual Basic keeps a stable design and stays straightforward; that when C# or the runtime add features that need language support, VB generally takes a "consumption-only" approach and avoids new syntax; that VB will not be extended to new workloads; and that Microsoft keeps investing in the Visual Studio experience and C# interop, especially for Windows Forms and libraries. The same article notes that some .NET workloads are not supported in VB and that VB developers commonly use C# for those.

Two things on this Mac confirm it in practice. The compiler lists the language versions it knows:

$ dotnet vbc.dll /langversion:?
Supported language versions: default 9 10 11 12 14 15 15.3 15.5 16 16.9 17.13 (default) latest

Version 17.13 is the default, but nothing in this lecture needed it: the working example below also built at LangVersion 16.9, and the required check fired the same way there. And dotnet new list --language VB shows console, class library, WinForms (app, control library, class library), WPF (four templates) and MSTest/NUnit/xUnit. There is no ASP.NET Core, Blazor, MAUI or worker-service template for VB.

So the realistic picture: VB is a maintained, stable language for desktop apps and libraries, and C# is where new workloads go. Nothing in this lecture contradicted that.

What builds on .NET 10, verified

Console and class library: dotnet new console -lang VB targets net10.0; everything in lectures 1 to 5 of this series ran on it.

WinForms, compile only. WinForms needs Windows to run, but it can be compiled elsewhere. Without the extra property the SDK refuses:

$ dotnet new winforms -lang VB -o wf && cd wf
$ dotnet build
…targets(120,5): error NETSDK1100: To build a project targeting Windows on this operating system, set the EnableWindowsTargeting property to true.
$ dotnet build -p:EnableWindowsTargeting=true
  wf -> ~/demo/wf/bin/Debug/net10.0-windows/wf.dll
Build succeeded.
    0 Warning(s)
    0 Error(s)

The template targets net10.0-windows with <UseWindowsForms>true</UseWindowsForms> and <StartupObject>Sub Main</StartupObject>. We compiled it; we did not run it. Starting the DLL on macOS fails with "You must install or update .NET to run this application ... Framework: 'Microsoft.WindowsDesktop.App'", which is expected: the Windows Desktop runtime does not exist for macOS. The point of compiling on a Mac or Linux CI agent is to catch build errors early; the tests that touch forms still need Windows.

The test: a C# 14 library consumed from VB

Here is the C# side, one file in a dotnet new classlib project. Each type uses one feature that VB might not understand:

using System;
using System.Numerics;

namespace ModernLib;

public class Person
{
    public string Name { get; init; } = "";       // C# 9 init-only setter
    public int Age { get; set; }
}

public class Order
{
    public required string Id { get; set; }       // C# 11 required member
    public decimal Total { get; set; }
}

public ref struct Cursor                          // ref struct (C# 7.2)
{
    private ReadOnlySpan<char> _rest;
    public Cursor(ReadOnlySpan<char> text) { _rest = text; }
    public bool TryNext(out int value)
    {
        value = 0;
        if (_rest.IsEmpty) return false;
        int comma = _rest.IndexOf(',');
        var token = comma < 0 ? _rest : _rest[..comma];
        _rest = comma < 0 ? default : _rest[(comma + 1)..];
        value = int.Parse(token);
        return true;
    }
}

public static class Spans
{
    public static int SumDigits(ReadOnlySpan<char> s)
    {
        int t = 0;
        foreach (var c in s) if (char.IsAsciiDigit(c)) t += c - '0';
        return t;
    }
    public static int CountCommas(ReadOnlySpan<char> s) => s.Count(',');
    // VB-friendly wrappers: string in, int out. The Span stays inside C#.
    public static int SumDigits(string s) => SumDigits(s.AsSpan());
    public static int SumCsv(string csv)
    {
        var cur = new Cursor(csv);
        int total = 0;
        while (cur.TryNext(out var v)) total += v;
        return total;
    }
}

public interface IParsable2<TSelf> where TSelf : IParsable2<TSelf>
{
    static abstract TSelf Parse(string s);          // C# 11 static abstract member
}

public readonly record struct Meters(double Value) : IParsable2<Meters>
{
    public static Meters Parse(string s) => new(double.Parse(s.TrimEnd('m')));
}

public static class Generic
{
    public static T ParseAll<T>(string s) where T : IParsable2<T> => T.Parse(s);
    public static T Sum<T>(T[] xs) where T : INumber<T>     // generic math (.NET 7)
    {
        T t = T.Zero;
        foreach (var x in xs) t += x;
        return t;
    }
}

public interface IGreeter
{
    string Name { get; }
    string Greet() => $"Hello, {Name}";            // C# 8 default interface method
}

public static class TextExtensions
{
    extension(string s)                            // C# 14 extension block
    {
        public bool IsBlank => string.IsNullOrWhiteSpace(s);   // extension property
        public string Shout() => s.ToUpperInvariant() + "!";    // extension method
    }
}

The VB console project references it with dotnet add reference ../ModernLib/ModernLib.csproj. Both target net10.0; one dotnet build builds both.

What VB cannot consume: the real BC errors

First, the code a VB developer would naturally write against that library:

Option Strict On
Imports ModernLib

Module Program
    Sub Main()
        Dim p As New Person With {.Name = "Kim"}
        p.Name = "Lee"                                   ' init-only after construction
        Dim span As ReadOnlySpan(Of Char) = "a1b2".AsSpan()
        Dim c As New Cursor("1,2,3")
        Dim blank = "  ".IsBlank
    End Sub
End Module

Class VbMeters
    Implements IParsable2(Of VbMeters)
End Class

Class VbGreeter
    Implements IGreeter
    Public ReadOnly Property Name As String Implements IGreeter.Name
        Get
            Return "VB"
        End Get
    End Property
End Class

The first build reports only the two class-level errors:

Program.vb(17,16): error BC37315: Class 'VbMeters' cannot implement interface 'IParsable2(Of VbMeters)' because it contains shared abstract or virtual 'Function Parse(s As String) As VbMeters'.
Program.vb(21,16): error BC30149: Class 'VbGreeter' must implement 'Function Greet() As String' for interface 'IGreeter'.

Only after removing the two classes did the method-body errors appear. Expect errors to arrive in waves when you first point VB at a modern C# library:

Program.vb(7,9): error BC37311: Init-only property 'Name' can only be assigned by an object member initializer, or on 'Me', 'MyClass` or 'MyBase' in an instance constructor.
Program.vb(8,21): error BC30668: 'ReadOnlySpan(Of Char)' is obsolete: 'Types with embedded references are not supported in this version of your compiler.'.
Program.vb(11,22): error BC30668: 'Cursor' is obsolete: 'Types with embedded references are not supported in this version of your compiler.'.
Program.vb(12,21): error BC30456: 'IsBlank' is not a member of 'String'.

And the parts that worked, which surprised us more than the errors:

Dim p As New Person With {.Name = "Kim", .Age = 30}   ' init-only via object initializer
Dim o As New Order With {.Id = "A-1"}                  ' required member set
Console.WriteLine($"Span wrapper : {Spans.SumDigits("a1b2c3")} / {Spans.SumCsv("10,20,30")}")
Console.WriteLine($"Span-only    : {Spans.CountCommas("a,b,c")}")
Dim m = Generic.ParseAll(Of Meters)("12.5m")
Console.WriteLine($"generic math : {Generic.Sum(New Integer() {1, 2, 3})} {Generic.Sum(New Double() {0.5, 0.25})}")
Console.WriteLine($"ext. method  : {"hi".Shout()}")
Console.WriteLine($"ext. property: {TextExtensions.get_IsBlank("  ")}")
init-only    : Kim
required     : Id = A-1
Span wrapper : 6 / 60
Span-only    : 2
static abstr.: 12.5
generic math : 6 0.75
ext. method  : HI!
ext. property: True

The summary, all checked on this compiler:

C# feature VB on .NET 10 Evidence
init setter Set in With {…} only BC37311 on later assignment; at LangVersion 16 even the initializer fails with BC36716
required member Enforced New Order() → BC37321, even at LangVersion 16.9
Span/ReadOnlySpan, ref struct named as a type Not allowed BC30668 "Types with embedded references…"
Method with a ReadOnlySpan(Of Char) parameter, passed a String Works CountCommas("a,b,c") = 2
Calling a generic method constrained by static abstract Works ParseAll(Of Meters) = 12.5
Implementing an interface with static abstract members Not allowed BC37315
Generic math (INumber(Of T)) Works for calls Sum of Integer and Double
Default interface method Must be implemented anyway BC30149
C# 14 extension method in an extension block Works "hi".Shout()
C# 14 extension property Not as a property BC30456; TextExtensions.get_IsBlank(s) works but is ugly

The required row corrects a common belief: VB does not silently ignore it. The compiler reports BC37321 ("Required member … must be set in the object initializer or attribute arguments").

Common mistake: Span "works" in VB, then crashes

BC30668 fires when you write the Span type's name. Let type inference pick it, and the compiler accepts it:

Dim inferred = "a1b2".AsSpan()                   ' type not written
Console.WriteLine($"Length = {inferred.Length}")   ' prints: Length = 4

That looks like a loophole. It is a trap, because VB does not know the safety rules for ref structs. Capture that variable in a lambda:

Sub Main()
    Console.WriteLine("start")
    Dim inferred = "a1b2".AsSpan()
    Dim f = Function() inferred.Length              ' lambda captures a Span
    Console.WriteLine(f())
End Sub

What actually happens: the build reports 0 errors and 0 warnings, and the program dies before printing "start":

Unhandled exception. System.TypeLoadException: A ByRef or ByRef-like type cannot be used as the type for an instance field in a non-ByRef-like type.
   at VbErr.Program.Main()

The lambda turned inferred into a field of a compiler-generated closure class, and the runtime refuses to load a class with a Span field. Because the failure happens when Main is compiled by the JIT, not when the line runs, even the first Console.WriteLine never executes. A C# compiler would have rejected this at build time. Keeping such a variable alive across an Await would turn it into a state-machine field in the same way; we did not run that case.

Fix: never let a Span reach VB code at all. Give the C# library a VB-friendly overload (SumDigits(String), SumCsv(String) above) and keep every Span inside C#.

The mixed-solution pattern: keep VB, add C# beside it

That fix is also the architecture. You do not have to choose between "all VB" and "rewrite":

Diagram. The existing VB.NET app, a console or WinForms project, has a project reference to a C# class library. The library's public API uses only VB-friendly types such as String, arrays, Task and ordinary classes, for example SumCsv(String) As Integer. Inside the library, C# code uses ReadOnlySpan, a ref struct cursor, static abstract interface members and extension blocks. Those features never cross into the VB project. Both projects target net10.0 and are built by the same dotnet build.
The mixed-solution pattern we built: the VB app keeps its code and calls a small C# library that exposes plain types; Span, ref structs and new C# features stay inside C#.
  1. Keep the VB project as it is, retargeted to net10.0 (or net10.0-windows for WinForms).
  2. Add a C# class library to the same solution and a project reference from VB to C#.
  3. Write new code that needs modern features (Span parsing, async streams, source generators, static abstract interfaces) in that library.
  4. Expose only types VB handles well: String, arrays, List(Of T), Task(Of T), plain classes and records with ordinary or init properties. For async streams, expose Task(Of List(Of T)) or a callback instead of IAsyncEnumerable, since VB has no Await For Each (see lecture 5).
  5. Move VB modules into C# only when you are changing them anyway.

The reverse reference (a C# project using a VB library) needs nothing special: a VB class library is ordinary .NET metadata. Note that the project reference also carries build properties: dotnet build -p:LangVersion=17 on the VB project failed with error CS1617 from the C# project, because a global -p: property applies to every project in the build. Put LangVersion in each project file instead.

Measured: the same loop in VB and C

Both compilers produce IL for the same runtime, so the question is only whether they produce different IL. We decoded the method bodies with MethodBody.GetILAsByteArray() and a small opcode table:

Function SumAllVb(a As Integer()) As Long
    Dim total As Long = 0
    For i = 0 To a.Length - 1
        total += a(i)
    Next
    Return total
End Function
public static long SumAll(int[] a)
{
    long total = 0;
    for (int i = 0; i < a.Length; i++) total += a[i];
    return total;
}

For the related SumEven loop the decoder printed:

C#  SumEven: 33 bytes IL, ops: add rem
VB  SumEven: 37 bytes IL, ops: add.ovf rem

The difference is add.ovf: VB checks every integer addition for overflow unless you set <RemoveIntegerChecks>true</RemoveIntegerChecks>. We timed both methods in the same process over 10,000,000 Integers from Random(42), 5 warm-up calls, then the median of 30 runs, three process runs each:

default build:            SumAll 10M ints: C# 6.50 / 6.50 / 6.45 ms | VB 8.49 / 8.42 / 8.36 ms
RemoveIntegerChecks=true: SumAll 10M ints: C# 6.46 / 6.46 / 6.63 ms | VB 6.46 / 6.45 / 6.66 ms
Bar chart of the median time to sum ten million integers. C#: 6.50 ms. VB with default settings, which emits overflow-checked add instructions: 8.42 ms. VB built with RemoveIntegerChecks set to true: 6.46 ms.
Summing 10 million Integers into a Long, same loop in C# and VB in one process (median of 30 runs after warm-up). VB's default overflow checks cost about 30% here; RemoveIntegerChecks makes it equal. .NET 10.0.14, Release, Apple M1 Pro.

So VB's default was about 30% slower on this tight loop, and identical once the checks were off. On a branchy loop (summing only even numbers, where branch misprediction dominates) the gap shrank to about 2%: C# 33.7–34.2 ms vs VB 34.1–35.3 ms. Real applications spend their time in I/O and libraries, so the language rarely matters; tight numeric loops are the exception.

The overflow check is also a correctness difference when you port code. A hash function copied from C#:

Function HashVb(a As Integer()) As Integer
    Dim h = 17
    For Each x In a
        h = h * 31 + x
    Next
    Return h
End Function
C# Hash = -2025638595
VB Hash -> OverflowException: Arithmetic operation resulted in an overflow.

C# wraps silently; VB throws. With RemoveIntegerChecks both printed -2025638595. (Lecture 1 shows the same with Integer.MaxValue + 1.) Turn the checks off per project only if you understand which code relies on wrapping; the safer fix for hash code is to do that arithmetic in the C# library or with Long and masking.

Migration decision table

Decision diagram. Start with an existing VB.NET codebase. First question: does it still change often? If no, keep it in VB and only retarget it to .NET 10. If yes, second question: do new features need things VB cannot consume, such as Span-based APIs, async streams or a web front end? If no, keep writing VB. If yes, add new code in C# class libraries next to the VB project and move modules over gradually. A full rewrite is only for small codebases or ones that are being redesigned anyway.
Keep, grow alongside, or rewrite: the questions that decided it in our table.
Situation Recommendation Why
Stable WinForms/console app, few changes Keep in VB, retarget to .NET 10 Compiles and runs; the strategy promises a stable language and Windows Forms support
Active app, new features are ordinary business logic Keep writing VB Nothing in our tests blocked plain classes, LINQ, async, generics
Active app, new features need Span APIs, async streams, source generators Gradual: new C# library beside VB VB cannot name Span types (BC30668) or consume IAsyncEnumerable with a loop
You must implement modern BCL interfaces (INumber(Of T), IParsable(Of T)) Gradual: that type in C# VB cannot implement static abstract members (BC37315)
Need a web API, Blazor or MAUI front end Gradual: new C# project, share VB libraries No VB templates for these workloads; the strategy says VB won't be extended to them
Small codebase, or a redesign is planned anyway Rewrite in C# Mixed solutions have a cost: two languages to review and hire for
Large codebase "because VB is dead" Don't rewrite on that basis alone The runtime, libraries and IL performance are shared; the measurable difference was overflow checking

Honest limits / version notes

  • Everything ran on .NET SDK 10.0.403 / runtime 10.0.14 on macOS. WinForms was compiled only (-p:EnableWindowsTargeting=true, using the Windows Desktop reference pack already in the local NuGet cache); it was not run. WPF was not built.
  • We checked language-version gates only where noted: init-only consumption fails at LangVersion 16 (BC36716) and works at 16.9; required enforcement happened at both 16.9 and 17.13. Other rows were checked only with the default 17.13.
  • The strategy summary paraphrases the Microsoft Learn article ".NET Managed languages strategy" as it read in November 2026; check the page for the current wording.
  • The Span-in-lambda crash is our own reproduction. We did not test every way a ref struct can escape in VB (for example across Await, in iterators or when boxed); assume all of them are unsafe.
  • Timings come from one Apple M1 Pro, Release builds, median of 30 runs after 5 warm-up calls, three process runs; they show a ratio, not an absolute speed. We did not measure build times.

Previous: VB.NET lecture 5/6: Async/Await. This is the last lecture of the VB.NET series; start again at lecture 1: Option Strict and types. For the C# side of the interop, see C# interfaces, C# generics and C# struct. The parallel advanced lectures are Java: JVM, JIT and GC and Python profiling and optimization.

FAQ

Is VB.NET still supported on .NET 10?

Yes. The .NET 10 SDK ships the VB compiler (default language version 17.13) and VB templates for console apps, class libraries, WinForms, WPF and test projects. Microsoft's '.NET Managed languages strategy' page on Microsoft Learn says VB keeps a stable design, adopts new C# and runtime features on a consumption-only basis, and will not be extended to new workloads. There is no ASP.NET Core template for VB.

Can VB.NET use a C# library that has init-only or required properties?

Yes, both. VB sets init-only properties in an object initializer (assigning later gives BC37311), and it enforces C# required members: New Order() without setting Id failed with BC37321. What VB cannot do is name Span or ref struct types (BC30668), implement interfaces with static abstract members (BC37315), use C# 14 extension properties (BC30456) or inherit default interface methods (BC30149).

Is VB.NET slower than C#?

The IL is nearly identical, with one exception: VB checks integer overflow by default. Summing 10 million Integers took 8.42 ms in VB and 6.50 ms in C# (median of 30, Release, Apple M1 Pro). With RemoveIntegerChecks set to true, VB took 6.46 ms, the same as C#.

  • #VB.NET
  • #.NET 10
  • #C#
  • #interop
  • #migration
  • #advanced

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑