PONYλM2Modula-2

Visual Basic.CodeCompared.To/Go

An interactive executable cheatsheet comparing Visual Basic and Go

Visual Basic (.NET 10) Go 1.26.5
Output & Running
Hello, World
Two languages that both keep a wrapper around the smallest program, arranged almost identically.
Option Strict On Imports System Module HelloWorld Sub Main() Console.WriteLine("Hello, World!") End Sub End Module
package main import "fmt" func main() { fmt.Println("Hello, World!") }
Module becomes package main — the package named main is what produces an executable — and Sub Main() becomes func main(). Imports System becomes import "fmt", naming the package that holds the printing functions. Blocks use braces and there are no semicolons; the compiler inserts them at line ends, which is why the opening brace must stay on the same line. Indentation is tabs, decided by gofmt rather than by you.
Formatted output
There is no interpolated string; the format verbs do the work, and one of them is worth learning immediately.
Option Strict On Imports System Module FormatDemo 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
package main import "fmt" func main() { name := "Ada" score := 42 ratio := 0.8756 fmt.Printf("Hello, %s! Score: %d\n", name, score) fmt.Printf("Padded: %05d, rounded: %.2f\n", score, ratio) fmt.Printf("%v and %+v\n", []int{1, 2}, struct{ A int }{1}) }
Go has no $"...". fmt.Printf uses C-style verbs — %s, %d, %.2f, %05d — and needs an explicit \n, while fmt.Println supplies one. The verb with no .NET counterpart is %v, "print this value however it should look", and %+v, which adds struct field names. Between them they replace most of what you would write a ToString override for.
Unused things are errors
The first thing that will stop your build, and it is a deliberate design decision rather than an oversight.
Option Strict On Imports System Imports System.Text Module UnusedDemo Sub Main() ' System.Text is imported and unused — a warning at most Dim total As Integer = 10 Dim unusedValue As Integer = 5 Console.WriteLine(total) End Sub End Module
package main import "fmt" func main() { total := 10 // An unused variable or import is a COMPILE ERROR: // unusedValue := 5 // declared and not used // import "strings" // imported and not used _ = total // the blank identifier deliberately discards fmt.Println(total) }
An unused local variable or an unused import is a compile error in Go, not a warning. This is jarring while you are experimenting and valuable in a codebase: dead code cannot accumulate. The escape hatch is _, the blank identifier, which discards a value on purpose — you will use it constantly for return values you do not want. Unused struct fields, function parameters and package-level variables are all still fine.
Syntax Fundamentals
Dim becomes := (or var)
Two forms: one that infers and declares in a single token, and one that spells the type out.
Option Strict On Imports System Module DeclarationDemo Sub Main() Dim count As Integer = 10 Dim label As String = "widget" Dim ready As Boolean Const MaximumRetries As Integer = 3 count = 20 Console.WriteLine($"{count} {label} {ready} {MaximumRetries}") End Sub End Module
package main import "fmt" const MaximumRetries = 3 func main() { count := 10 label := "widget" var ready bool // zero value: false count = 20 fmt.Println(count, label, ready, MaximumRetries) }
:= declares and infers in one step and works only inside a function — it is what you will write nearly always. var name Type is the explicit form, and it is what you need when there is no initializer, at package level, or when the inferred type would be wrong. The type goes after the name, reading left to right: var ready bool. Const becomes const, and constants are untyped until used, which lets one constant serve several numeric types.
A capital letter is the access modifier
There is no Public or Private keyword at all — the first letter of the name decides.
Option Strict On Imports System Public Class Report Public Property Title As String = "" Private _internalId As Integer = 1 Public Function Describe() As String Return $"{Title} ({_internalId})" End Function End Class Module ExportDemo Sub Main() Dim report As New Report With {.Title = "Quarterly"} Console.WriteLine(report.Describe()) End Sub End Module
package main import "fmt" type Report struct { Title string // exported — visible outside the package internalID int // lower case — package-private } func (r Report) Describe() string { return fmt.Sprintf("%s (%d)", r.Title, r.internalID) } func main() { report := Report{Title: "Quarterly", internalID: 1} fmt.Println(report.Describe()) }
A name beginning with a capital letter is exported from its package; a lower-case one is not. That is the entire access-control system: two levels, package and exported, with no Protected, no Friend and no per-class privacy. It has a real consequence for naming — renaming a field changes its visibility — and it means you can tell at a glance, at every use site, whether something crosses a package boundary.
Formatting is not a preference
One tool formats every Go file in the world the same way, and the community treats its output as the only correct form.
Option Strict On Imports System Module FormatStyleDemo Sub Main() ' Visual Basic style is a team decision: ' tabs or spaces, brace-free blocks, naming Dim values() As Integer = {1, 2, 3} For Each value As Integer In values Console.Write(value & " ") Next Console.WriteLine() End Sub End Module
package main import "fmt" func main() { // gofmt decides all of it: tabs, alignment, spacing, // where the braces go. There is no style debate. values := []int{1, 2, 3} for _, value := range values { fmt.Print(value, " ") } fmt.Println() }
gofmt ships with the compiler, runs on save in every editor, and is not configurable. Tabs for indentation, a fixed brace position, aligned struct fields. The effect is that all Go code looks alike, code review never discusses layout, and a diff only ever shows a real change. Coming from a language with no such tool this feels authoritarian for about a week and then becomes one of the things you miss elsewhere.
Types & Zero Values
Every type has a zero value
Nothing is ever uninitialized, and the value it starts with is chosen to be useful.
Option Strict On Imports System Imports System.Collections.Generic Module ZeroDemo Sub Main() Dim number As Integer Dim text As String Dim flag As Boolean Dim items As List(Of Integer) Console.WriteLine(number) Console.WriteLine(text Is Nothing) Console.WriteLine(flag) Console.WriteLine(items Is Nothing) End Sub End Module
package main import "fmt" type Settings struct { Retries int Name string } func main() { var number int var text string var flag bool var settings Settings fmt.Println(number) fmt.Printf("%q\n", text) fmt.Println(flag) fmt.Printf("%+v\n", settings) }
Numbers start at 0, strings at "", booleans at false, pointers and slices and maps at nil, and a struct at a struct whose every field is its own zero. Note the difference from Visual Basic: an uninitialized String is Nothing there and "" here, so a zero-value Go string can be used immediately. The design goal is that the zero value should be ready to use — a zero sync.Mutex is an unlocked mutex, a zero bytes.Buffer is an empty buffer.
Structs replace classes
There are no classes — a type holds data, and methods are attached to it from outside.
Option Strict On Imports System Public Class Person Public ReadOnly Property Name As String Public ReadOnly Property Age As Integer Public Sub New(name As String, age As Integer) Me.Name = name Me.Age = age End Sub Public Function Describe() As String Return $"{Name}, age {Age}" End Function End Class Module StructDemo Sub Main() Dim person As New Person("Ada", 36) Console.WriteLine(person.Describe()) End Sub End Module
package main import "fmt" type Person struct { Name string Age int } // A method is a function with a receiver, declared OUTSIDE the type func (p Person) Describe() string { return fmt.Sprintf("%s, age %d", p.Name, p.Age) } // Convention: a constructor is a plain function named New... func NewPerson(name string, age int) Person { return Person{Name: name, Age: age} } func main() { person := NewPerson("Ada", 36) fmt.Println(person.Describe()) fmt.Println(person == NewPerson("Ada", 36)) }
A struct is data only. A method is a function with a receiverfunc (p Person) Describe() — written at package level rather than inside the type, which means you can add methods to any type your package defines. There is no constructor: the convention is an ordinary function called NewPerson. And because a struct is a value type compared field by field, == works on it directly, with no Equals to override.
Pointers, and when you need one
Go makes explicit the distinction Visual Basic makes by whether you wrote Class or Structure.
Option Strict On Imports System Public Class Counter Public Property Total As Integer End Class Public Structure Point Public X As Integer End Structure Module PointerDemo Sub Increment(counter As Counter) counter.Total += 1 End Sub Sub Move(point As Point) point.X += 1 End Sub Sub Main() Dim counter As New Counter() Increment(counter) Console.WriteLine(counter.Total) Dim point As New Point() Move(point) Console.WriteLine(point.X) End Sub End Module
package main import "fmt" type Counter struct{ Total int } // Value receiver: works on a COPY func (c Counter) IncrementCopy() { c.Total++ } // Pointer receiver: works on the original func (c *Counter) Increment() { c.Total++ } func main() { counter := Counter{} counter.IncrementCopy() fmt.Println(counter.Total) counter.Increment() // Go takes the address for you fmt.Println(counter.Total) pointer := &counter fmt.Println(pointer.Total) }
Everything is passed by value. A pointer receiver, *Counter, is what lets a method change the original, and &value takes an address. Go smooths the calling side: counter.Increment() works on a value because the compiler inserts the &. There is no pointer arithmetic and no manual freeing — this is a garbage-collected language — so a pointer here is closer to a .NET reference than to a C one. The rule of thumb: use a pointer receiver if the method mutates or the struct is large, and be consistent within a type.
Conversions are always explicit
Stricter than Option Strict On: even a widening conversion has to be written out.
Option Strict On Imports System Module ConversionDemo Sub Main() Dim whole As Integer = 7 ' Option Strict On still WIDENS silently Dim widened As Double = whole Dim text As String = CStr(whole) Dim parsed As Integer = CInt("123") Console.WriteLine($"{widened} {text} {parsed}") End Sub End Module
package main import ( "fmt" "strconv" ) func main() { whole := 7 widened := float64(whole) // required — no implicit widening text := strconv.Itoa(whole) parsed, err := strconv.Atoi("123") if err != nil { fmt.Println("not a number") return } fmt.Println(widened, text, parsed) }
Go performs no implicit numeric conversion at all, so int plus float64 is a compile error until you write float64(whole). String conversion is not a cast: strconv.Itoa for int→string, strconv.Atoi for string→int, strconv.FormatFloat and ParseFloat for floats. Note that Atoi returns two values, the result and an error — which is the subject of the errors section and the single biggest difference in this whole comparison.
Slices & Maps
Slices replace List(Of T)
The growable list is a language built-in — note that appending returns the new slice rather than changing it in place.
Option Strict On Imports System Imports System.Collections.Generic Module SliceDemo Sub Main() Dim fruits As New List(Of String) From {"apple", "banana"} fruits.Add("cherry") Console.WriteLine(fruits.Count) Console.WriteLine(fruits(0)) Console.WriteLine(String.Join(", ", fruits)) End Sub End Module
package main import ( "fmt" "strings" ) func main() { fruits := []string{"apple", "banana"} fruits = append(fruits, "cherry") fmt.Println(len(fruits)) fmt.Println(fruits[0]) fmt.Println(strings.Join(fruits, ", ")) fmt.Println(fruits[1:]) fmt.Println(fruits[:2]) }
List(Of String) becomes []string. Add becomes append(slice, item), and you must assign the result: appending may allocate a new backing array, so append(fruits, x) on its own is a bug the compiler will flag as an unused value. Count becomes len(), a built-in function rather than a property. Slicing with [low:high] gives a view onto the same memory, which is fast and — as the gotchas section shows — occasionally surprising.
Maps
Reading a missing key does not throw — it hands back the zero value, which is why the two-result form exists.
Option Strict On Imports System Imports System.Collections.Generic Module MapDemo Sub Main() Dim ages As New Dictionary(Of String, Integer) From { {"Ada", 36}, {"Grace", 45} } ages("Alan") = 41 Console.WriteLine(ages.Count) Console.WriteLine(ages("Ada")) Console.WriteLine(ages.ContainsKey("Nobody")) Dim found As Integer ages.TryGetValue("Nobody", found) Console.WriteLine(found) End Sub End Module
package main import ( "fmt" "sort" ) func main() { ages := map[string]int{"Ada": 36, "Grace": 45} ages["Alan"] = 41 fmt.Println(len(ages)) fmt.Println(ages["Ada"]) fmt.Println(ages["Nobody"]) // 0 — the zero value, not an error value, ok := ages["Nobody"] fmt.Println(value, ok) names := make([]string, 0, len(ages)) for name := range ages { names = append(names, name) } sort.Strings(names) // map order is RANDOM — sort to be deterministic fmt.Println(names) }
Dictionary(Of K, V) becomes map[K]V, and ContainsKey/TryGetValue both become the comma-ok form: value, ok := ages[key], where ok says whether the key was present. Without it you cannot tell a stored zero from a missing key. The other thing to know: map iteration order is deliberately randomized — it differs between runs of the same program — so any output that must be stable needs the keys collected and sorted, as above.
range is how you iterate
One keyword iterates slices, maps, strings, channels and — since Go 1.22 — plain integers.
Option Strict On Imports System Imports System.Collections.Generic Module RangeDemo 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
package main import ( "fmt" "strings" ) func main() { words := []string{"alpha", "beta", "gamma"} for _, word := range words { fmt.Println(strings.ToUpper(word)) } for index, word := range words { fmt.Printf("%d: %s\n", index, word) } for index := range 3 { fmt.Print(index, " ") } fmt.Println() }
For Each x In items becomes for _, x := range items. range always yields the index first and the value second, which is why the blank identifier appears so often: you usually want only the value. Over a map it yields key and value; over a string it yields byte offsets and runes. for i := range 3 counts 0, 1, 2, which is the closest thing to For i = 0 To 2. Note ToUpper is a package function, strings.ToUpper(word), not a method — strings are a built-in type with no methods of their own.
Control Flow
If, and the statement that comes before it
Braces, no parentheses — and an if may run a statement first, scoping its result to the branches.
Option Strict On Imports System Module IfDemo Sub Main() Dim value As Integer Dim parsed As Boolean = Integer.TryParse("42", value) If parsed AndAlso value > 25 Then Console.WriteLine("Large") ElseIf parsed Then Console.WriteLine("Small") Else Console.WriteLine("Not a number") End If End Sub End Module
package main import ( "fmt" "strconv" ) func main() { if value, err := strconv.Atoi("42"); err != nil { fmt.Println("Not a number") } else if value > 25 { fmt.Println("Large") } else { fmt.Println("Small") } }
The condition takes no parentheses and the braces are mandatory, even for one statement. ElseIf becomes else if, and the else must sit on the same line as the closing brace — a consequence of automatic semicolon insertion. The addition is the init statement: if value, err := ...; err != nil declares variables scoped to the whole if/else chain, which is the idiomatic way to handle the two-value returns Go functions produce.
Select Case becomes switch
Closer to Select Case than any C-family switch: no break, and conditions are allowed as cases.
Option Strict On Imports System Module SelectDemo Function Describe(code As Integer) As String Select Case code Case 1 Return "one" Case 2, 3 Return "two or three" Case Is > 100 Return "large" Case Else Return "something else" End Select End Function Sub Main() Console.WriteLine(Describe(1)) Console.WriteLine(Describe(3)) Console.WriteLine(Describe(200)) End Sub End Module
package main import "fmt" func describe(code int) string { switch { case code == 1: return "one" case code == 2, code == 3: return "two or three" case code > 100: return "large" default: return "something else" } } func main() { fmt.Println(describe(1)) fmt.Println(describe(3)) fmt.Println(describe(200)) }
Go's switch does not fall through — each case ends by itself, so no break is needed anywhere, exactly as in Select Case. Several values share a case with a comma, matching Case 2, 3. A switch with no subject takes boolean cases, which is how Case Is > 100 translates. There is an explicit fallthrough keyword for the rare deliberate case, and Case Else becomes default.
for is the only loop
Four Visual Basic loop constructs collapse into one keyword wearing three hats.
Option Strict On Imports System Module LoopDemo Sub Main() For index As Integer = 1 To 5 Console.Write(index & " ") Next Console.WriteLine() Dim remaining As Integer = 3 While remaining > 0 remaining -= 1 End While Console.WriteLine(remaining) Dim attempt As Integer = 0 Do attempt += 1 Loop Until attempt >= 2 Console.WriteLine(attempt) End Sub End Module
package main import "fmt" func main() { for index := 1; index <= 5; index++ { fmt.Print(index, " ") } fmt.Println() remaining := 3 for remaining > 0 { // this is "while" remaining-- } fmt.Println(remaining) attempt := 0 for { // this is "do forever" attempt++ if attempt >= 2 { break } } fmt.Println(attempt) }
There is no while and no do: for condition { } is a while loop, for { } loops forever, and for init; cond; step { } is the counted form. To 5 becomes <= 5, the usual off-by-one to check. Exit For becomes break and Continue For becomes continue, both of which accept a label to act on an enclosing loop. There is no Until, so a Loop Until condition gets inverted or becomes a break.
There is no If() expression
No ternary, no null-coalescing operator, and no plan to add either.
Option Strict On Imports System Module TernaryDemo 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
package main import "fmt" func main() { score := 72 grade := "fail" if score >= 60 { grade = "pass" } supplied := "" label := supplied if label == "" { label = "(unnamed)" } fmt.Printf("%s / %s\n", grade, label) }
Go has no conditional expression. Both forms of If() become an if statement assigning to a variable declared just above — which is more lines and, the language designers argue, more readable when the branches grow. There is no ?? and no ?. either. This is representative of Go's whole approach: fewer ways to express a thing, and the one way is usually the plainest.
Functions
Sub and Function both become func
One keyword covers both, the types follow the names, and the return type sits after the parameter list.
Option Strict On Imports System Module FunctionDemo 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
package main import "fmt" func announce(message string) { fmt.Printf("** %s **\n", message) } func add(left, right int) int { return left + right } func main() { announce("starting") fmt.Println(add(2, 3)) }
Sub becomes a func with no return type; Function ... As Integer becomes func ... int. Parameters sharing a type may share the annotation, as left, right int does. A lower-case name is package-private and a capital one is exported, so announce here is internal. There is no overloading: two functions cannot share a name, so Add(Integer) and Add(Double) become AddInt and AddFloat, or one generic function.
Returning more than one thing
The TryParse shape — a boolean plus a ByRef — becomes ordinary and needs no output parameter.
Option Strict On Imports System Module MultipleDemo Function TryDivide(numerator As Integer, denominator As Integer, ByRef result As Integer) As Boolean If denominator = 0 Then Return False result = numerator \ denominator Return True End Function Sub Main() Dim answer As Integer If TryDivide(10, 2, answer) Then Console.WriteLine(answer) End If End Sub End Module
package main import "fmt" func divide(numerator, denominator int) (int, bool) { if denominator == 0 { return 0, false } return numerator / denominator, true } func minimumAndMaximum(values []int) (smallest, largest int) { smallest, largest = values[0], values[0] for _, value := range values { if value < smallest { smallest = value } if value > largest { largest = value } } return // named results are returned as they stand } func main() { if answer, ok := divide(10, 2); ok { fmt.Println(answer) } low, high := minimumAndMaximum([]int{4, 9, 1, 7}) fmt.Println(low, high) }
A function may return any number of values, listed in parentheses. That removes the need for ByRef entirely, and it is why strconv.Atoi and every map read hand back two things. Results may be named in the signature, as (smallest, largest int) is, in which case a bare return returns their current values — useful for documentation, and easy to overuse. Assigning several at once, low, high := ..., is the unpacking side.
Using becomes defer
Cleanup is registered where the resource is acquired, and runs when the function returns.
Option Strict On Imports System Imports System.IO Module DeferDemo Sub Main() Dim contents As String = "" Using writer As New StringWriter() writer.WriteLine("first line") contents = writer.ToString() End Using Console.WriteLine(contents.Trim()) End Sub End Module
package main import ( "fmt" "strings" ) func build() string { var builder strings.Builder defer fmt.Println("cleanup runs last, whatever happens") builder.WriteString("first line") return builder.String() } func main() { fmt.Println(build()) }
defer schedules a call for when the surrounding function exits — by return, by falling off the end, or by panic. So Using ... End Using becomes f, err := os.Open(...) immediately followed by defer f.Close(), and the cleanup sits two lines from the acquisition rather than at the far end of a block. Deferred calls run last-in-first-out, and their arguments are evaluated at the moment defer is written, not when it runs.
Closures and function values
Functions are values with no delegate type to declare, and they close over the variables around them.
Option Strict On Imports System Module ClosureDemo Sub Main() Dim twice As Func(Of Integer, Integer) = Function(value) value * 2 Dim total As Integer = 0 Dim accumulate As Action(Of Integer) = Sub(value) total += value accumulate(5) accumulate(7) Console.WriteLine(twice(21)) Console.WriteLine(total) End Sub End Module
package main import "fmt" func makeCounter() func() int { total := 0 return func() int { total++ return total } } func main() { twice := func(value int) int { return value * 2 } fmt.Println(twice(21)) counter := makeCounter() fmt.Println(counter()) fmt.Println(counter()) }
Func(Of Integer, Integer) becomes the type func(int) int, written inline — there is no Func/Action family and no AddressOf, since a function's name already is a value. A function literal captures the variables in scope, so makeCounter hands back a function with private state, which is Go's answer to a small stateful object. Each call to makeCounter gets its own total.
Errors Are Values
There are no exceptions
This is the single largest difference on the page, and it changes the shape of every function you write.
Option Strict On Imports System Module ErrorDemo Function Load(id As Integer) As String If id < 0 Then Throw New ArgumentException("negative id") Return $"record {id}" End Function Sub Main() Try Console.WriteLine(Load(1)) Console.WriteLine(Load(-1)) Catch error_ As ArgumentException Console.WriteLine($"failed: {error_.Message}") End Try End Sub End Module
package main import ( "errors" "fmt" ) func load(id int) (string, error) { if id < 0 { return "", errors.New("negative id") } return fmt.Sprintf("record %d", id), nil } func main() { record, err := load(1) if err != nil { fmt.Println("failed:", err) return } fmt.Println(record) if _, err := load(-1); err != nil { fmt.Println("failed:", err) } }
Go has no exceptions and no Try/Catch. A function that can fail returns an error as its last result, and the caller checks it — immediately, every time. if err != nil { return err } is the most common three lines in the language, and the verbosity is the deliberate trade: you can see every failure path on the page, and no error can travel silently past a caller who did not think about it. Returning nil for the error means success.
Adding context, and matching on it later
Wrapping an error is what InnerException does, and unwrapping it is a function rather than a cast.
Option Strict On Imports System Public Class NotFoundException Inherits Exception Public Sub New(message As String) MyBase.New(message) End Sub End Class Module WrapDemo Function Fetch(id As Integer) As String Throw New NotFoundException($"no record {id}") End Function Sub Main() Try Try Console.WriteLine(Fetch(7)) Catch inner As NotFoundException Throw New InvalidOperationException("loading failed", inner) End Try Catch outer As InvalidOperationException Console.WriteLine(outer.Message) Console.WriteLine(TypeOf outer.InnerException Is NotFoundException) End Try End Sub End Module
package main import ( "errors" "fmt" ) var ErrNotFound = errors.New("not found") func fetch(id int) (string, error) { return "", fmt.Errorf("loading record %d: %w", id, ErrNotFound) } func main() { _, err := fetch(7) fmt.Println(err) fmt.Println(errors.Is(err, ErrNotFound)) var target *MyError fmt.Println(errors.As(err, &target)) } type MyError struct{ Code int } func (e *MyError) Error() string { return fmt.Sprintf("code %d", e.Code) }
fmt.Errorf with the %w verb wraps an error, adding context while keeping the original reachable — the equivalent of passing an inner exception. errors.Is(err, ErrNotFound) then asks "is this, anywhere down the chain, that sentinel error", replacing Catch e As NotFoundException. errors.As is the version that extracts a specific error type so you can read its fields. A package-level ErrSomething value is the idiomatic way to let callers recognize a condition.
panic and recover
There is something exception-shaped after all — and the community view is that you should almost never use it.
Option Strict On Imports System Module PanicDemo Sub Main() Try Dim numbers() As Integer = {1, 2, 3} Console.WriteLine(numbers(10)) Catch error_ As IndexOutOfRangeException Console.WriteLine($"caught: {error_.Message}") End Try Console.WriteLine("carrying on") End Sub End Module
package main import "fmt" func risky() (result string, err error) { defer func() { if problem := recover(); problem != nil { err = fmt.Errorf("recovered: %v", problem) } }() numbers := []int{1, 2, 3} return fmt.Sprint(numbers[10]), nil } func main() { _, err := risky() fmt.Println(err) fmt.Println("carrying on") }
panic unwinds the stack running deferred functions, and recover — callable only inside a defer — stops it. So it can be made to behave like Try/Catch, as above. But it is not for ordinary failures: it is for programmer bugs, such as an index out of range, and for a library boundary that must not take the whole process down. A file that does not exist, a malformed number, a failed request — all of those are error values. Reaching for panic where an error belongs is the most common way ported .NET code reads wrongly in Go.
Methods & Interfaces
Interfaces are satisfied, not declared
Nothing says that Formal implements Greeter — it has the method, so it does.
Option Strict On Imports System Imports System.Collections.Generic Public Interface IGreeter Function Greet(name As String) As String End Interface Public Class Formal Implements IGreeter Public Function Greet(name As String) As String Implements IGreeter.Greet Return $"Good day, {name}." End Function End Class Module InterfaceDemo Sub Main() Dim greeters As New List(Of IGreeter) From {New Formal()} For Each greeter As IGreeter In greeters Console.WriteLine(greeter.Greet("Ada")) Next End Sub End Module
package main import "fmt" type Greeter interface { Greet(name string) string } type Formal struct{} // No "implements" clause anywhere func (Formal) Greet(name string) string { return "Good day, " + name + "." } func main() { greeters := []Greeter{Formal{}} for _, greeter := range greeters { fmt.Println(greeter.Greet("Ada")) } }
Go interfaces are structural: a type satisfies an interface by having the right methods, with no declaration on either side. The consequence that matters is that the interface can be defined by the consumer, after the fact, for a type it does not own — including types from someone else's package. Go convention keeps interfaces small (one or two methods, often named -er: Reader, Writer, Stringer) and defines them where they are used rather than where the implementations live.
There is no inheritance
The nearest thing to a base class is a field with no name — and it is composition, not inheritance.
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 Dog("Rex")} For Each pet As Animal In pets Console.WriteLine(pet.Speak()) Next End Sub End Module
package main import "fmt" type Animal struct { Name string } func (a Animal) Speak() string { return a.Name + " makes a sound" } func (a Animal) Describe() string { return "an animal called " + a.Name } type Dog struct { Animal // embedded: Dog gets Animal's methods } func (d Dog) Speak() string { return d.Name + " barks" } func main() { dog := Dog{Animal{Name: "Rex"}} fmt.Println(dog.Speak()) fmt.Println(dog.Describe()) fmt.Println(dog.Animal.Speak()) }
Go has no inheritance, no Inherits, no Overridable, no MyBase. Embedding a type puts its fields and methods on the outer type, so Dog answers Describe for free and can define its own Speak to shadow the inner one. The crucial difference from overriding: this is not polymorphic — a method on Animal calling Speak gets Animal's, never Dog's. Polymorphism comes only from interfaces, which is why Go code is designed around them.
ToString becomes String()
Overriding ToString becomes satisfying a one-method interface — and nothing has to be declared for it to take effect.
Option Strict On Imports System Public Structure Money Public ReadOnly Amount As Decimal Public ReadOnly Currency As String Public Sub New(amount As Decimal, currency As String) Me.Amount = amount Me.Currency = currency End Sub Public Overrides Function ToString() As String Return $"{Amount:F2} {Currency}" End Function End Structure Module StringerDemo Sub Main() Console.WriteLine(New Money(9.99D, "USD")) End Sub End Module
package main import "fmt" type Money struct { Amount float64 Currency string } func (m Money) String() string { return fmt.Sprintf("%.2f %s", m.Amount, m.Currency) } func main() { money := Money{9.99, "USD"} fmt.Println(money) fmt.Printf("%v | %+v\n", money, struct{ A int }{1}) }
Defining String() string makes a type satisfy fmt.Stringer, and every printing function uses it automatically. That is the structural-interface idea doing real work: no Overrides, no base class, just a method with the right name. Note also that Go has no Decimal: float64 is the practical default and is wrong for money, so a real accounting service uses integer minor units or math/big's Rat.
Goroutines & Channels
Goroutines
One keyword starts concurrent work, and there is no Task object handed back to wait on.
Option Strict On Imports System Imports System.Threading.Tasks Module TaskDemo Async Function WorkAsync(label As String) As Task(Of String) Await Task.Delay(1) Return $"done {label}" End Function Sub Main() Dim results = Task.WhenAll(WorkAsync("one"), WorkAsync("two")). GetAwaiter().GetResult() For Each value As String In results Console.WriteLine(value) Next End Sub End Module
package main import ( "fmt" "sort" "sync" ) func main() { var waiter sync.WaitGroup var mutex sync.Mutex results := []string{} for _, label := range []string{"one", "two"} { waiter.Add(1) go func() { defer waiter.Done() mutex.Lock() results = append(results, "done "+label) mutex.Unlock() }() } waiter.Wait() sort.Strings(results) for _, value := range results { fmt.Println(value) } }
go f() runs f concurrently. A goroutine is far cheaper than a thread — thousands are routine — but it returns nothing, so there is no Task to await. Coordination is separate: sync.WaitGroup waits for a set of them to finish, and sync.Mutex guards shared state, exactly as it would in .NET. Note that unlike JavaScript this is real parallelism across cores, so a data race is possible and the mutex is not decorative.
Channels
A typed pipe between goroutines, built into the language rather than supplied by a library.
Option Strict On Imports System Imports System.Collections.Concurrent Imports System.Threading.Tasks Module ChannelDemo Sub Main() Dim queue As New BlockingCollection(Of Integer)() Task.Run(Sub() For number As Integer = 1 To 3 queue.Add(number) Next queue.CompleteAdding() End Sub).Wait() For Each value As Integer In queue.GetConsumingEnumerable() Console.WriteLine(value) Next End Sub End Module
package main import "fmt" func produce(out chan<- int) { for number := 1; number <= 3; number++ { out <- number } close(out) } func main() { numbers := make(chan int) go produce(numbers) for value := range numbers { fmt.Println(value) } done := make(chan string, 1) done <- "finished" fmt.Println(<-done) }
A chan int carries values between goroutines; out <- value sends and <-channel receives. An unbuffered channel blocks until the other side is ready, which makes it a synchronization primitive as well as a queue. close ends the stream, and for value := range channel reads until it closes — the counterpart of GetConsumingEnumerable. chan<- int in a signature means send-only, which the compiler enforces. The community slogan is "share memory by communicating", meaning prefer a channel to a mutex where you can.
Waiting on several things, and cancelling
select waits on whichever channel is ready first — and context is how cancellation travels through a service.
Option Strict On Imports System Imports System.Threading Imports System.Threading.Tasks Module CancelDemo Sub Main() Dim source As New CancellationTokenSource() Dim token As CancellationToken = source.Token source.Cancel() Console.WriteLine(token.IsCancellationRequested) End Sub End Module
package main import ( "context" "fmt" ) func main() { ctx, cancel := context.WithCancel(context.Background()) results := make(chan string, 1) go func() { results <- "work finished" }() select { case value := <-results: fmt.Println(value) case <-ctx.Done(): fmt.Println("cancelled") } cancel() <-ctx.Done() fmt.Println(ctx.Err()) }
select is a switch over channel operations: it blocks until one is ready and takes that branch. Combined with a timeout channel it gives you "whichever happens first", which is Task.WhenAny. context.Context is the direct counterpart of CancellationToken, and Go convention passes it as the first parameter of every function that can block — you will see ctx context.Context throughout any server codebase. It carries deadlines and cancellation down the call tree.
Packages, Modules & Tooling
Imports becomes import
Imports name a package and you always reach through it — there is no way to pull a name into bare scope.
Option Strict On Imports System Imports Shortcut = System.Math Module ImportDemo Sub Main() Console.WriteLine(String.Join(", ", New String() {"a", "b"})) Console.WriteLine(Shortcut.Max(3, 7)) Console.WriteLine("go".ToUpper()) End Sub End Module
package main import ( "fmt" "math" "strings" ) func main() { fmt.Println(strings.Join([]string{"a", "b"}, ", ")) fmt.Println(math.Max(3, 7)) fmt.Println(strings.ToUpper("go")) }
An import brings the package into scope, and its members are always qualified: strings.Join, never a bare Join. There is no equivalent of Imports System.Math pulling names into bare scope, and no way to shorten a call — which is deliberate, since every call then says where it came from. Aliasing exists (str "strings") and is used mainly to resolve a name collision, not for brevity. Remember that an unused import is a compile error.
NuGet becomes modules, and the output is one file
The packaging story maps across, and the last two lines are the reason people choose Go for services.
Option Strict On Imports System Imports System.Collections.Generic Module DeployDemo Sub Main() Dim story As New Dictionary(Of String, String) From { {"manifest", ".vbproj"}, {"registry", "NuGet"}, {"restore", "dotnet restore"}, {"output", "dll plus a runtime"} } For Each entry In story Console.WriteLine($"{entry.Key}: {entry.Value}") Next End Sub End Module
package main import "fmt" func main() { story := [][2]string{ {"manifest", "go.mod"}, {"registry", "proxy.golang.org"}, {"fetch", "go mod tidy"}, {"output", "one static binary, no runtime"}, {"test", "go test ./... — built in"}, } for _, entry := range story { fmt.Printf("%s: %s\n", entry[0], entry[1]) } }
go.mod is the .vbproj, go mod tidy is dotnet restore, and modules are fetched by URL rather than from a central registry with an account. The differences worth the move: go build produces one statically linked binary with no runtime to install — you copy a file to the server and run it, which is a materially smaller deployment story than shipping .NET. And go test, go vet and gofmt are part of the toolchain, so a project needs no test framework, no linter package and no formatter argument.
⚠ Gotchas for Visual Basic Programmers
⚠ / truncates between integers
The familiar trap, made slightly less dangerous by Go's refusal to convert anything for you.
Option Strict On Imports System Module DivisionGotcha Sub Main() Dim average As Double = (3 + 4) / 2 Console.WriteLine(average) End Sub End Module
package main import "fmt" func main() { average := (3 + 4) / 2 fmt.Println(average) correct := float64(3+4) / 2.0 fmt.Println(correct) }
Go has no \, and / between two integers truncates — so a Visual Basic / copied across silently starts doing integer division. What limits the damage is that Go performs no implicit conversion: writing (3 + 4) / 2.0 is a compile error rather than a wrong answer, because an untyped integer expression cannot mix with a float variable of the wrong type. Being made to write float64(...) is the prompt to think about it.
⚠ A nil map reads fine and explodes on write
The asymmetry is the trap: reading a nil map is legal, so the bug hides until something writes.
Option Strict On Imports System Imports System.Collections.Generic Module NilMapGotcha Sub Main() Dim ages As Dictionary(Of String, Integer) = Nothing Try Console.WriteLine(ages("Ada")) Catch error_ As NullReferenceException Console.WriteLine("reading a Nothing dictionary throws") End Try End Sub End Module
package main import "fmt" func main() { var ages map[string]int // nil, not empty fmt.Println(ages["Ada"]) // 0 — reading is FINE fmt.Println(len(ages)) // 0 — fine too // ages["Ada"] = 36 // PANIC: assignment to entry in nil map ages = make(map[string]int) // or map[string]int{} ages["Ada"] = 36 fmt.Println(ages["Ada"]) }
A declared-but-not-initialized map is nil. Reading from it returns the zero value and len is 0, so code that only reads works perfectly. Writing to it panics at runtime. A nil map must be created with make(map[K]V) or a literal before any assignment. A nil slice, by contrast, is genuinely usable — append to a nil slice works and returns a real one — which makes the map behaviour easier to forget.
⚠ A slice is a view onto shared memory
Slicing looks like Skip/Take and does something quite different.
Option Strict On Imports System Imports System.Collections.Generic Imports System.Linq Module SliceGotcha Sub Main() Dim original As New List(Of Integer) From {1, 2, 3, 4} ' Every one of these makes a COPY Dim part As List(Of Integer) = original.Skip(1).Take(2).ToList() part(0) = 99 Console.WriteLine(String.Join(", ", original)) Console.WriteLine(String.Join(", ", part)) End Sub End Module
package main import "fmt" func main() { original := []int{1, 2, 3, 4} part := original[1:3] // a VIEW, not a copy part[0] = 99 fmt.Println(original) fmt.Println(part) safe := make([]int, 2) copy(safe, original[1:3]) safe[0] = 7 fmt.Println(original, safe) }
A slice is a length, a capacity and a pointer into a backing array. Slicing does not copy: original[1:3] shares memory with original, so writing through one is visible in the other. LINQ's Skip/Take always produce independent sequences, so this catches people constantly. When you need a real copy, allocate one and use the built-in copy. The same sharing means append can sometimes write into a neighbouring slice's memory and sometimes reallocate, depending on capacity.
⚠ No overloading, no optional arguments
Two features you use without thinking are simply absent, and the idioms that replace them are worth learning early.
Option Strict On Imports System Module OverloadGotcha Function Greet(name As String, Optional greeting As String = "Hello") As String Return $"{greeting}, {name}" End Function Function Area(side As Double) As Double Return side * side End Function Function Area(width As Double, height As Double) As Double Return width * height End Function Sub Main() Console.WriteLine(Greet("Ada")) Console.WriteLine(Area(3)) Console.WriteLine(Area(3, 4)) End Sub End Module
package main import "fmt" type GreetOptions struct { Greeting string } func Greet(name string, options GreetOptions) string { if options.Greeting == "" { options.Greeting = "Hello" // the zero value means "unset" } return options.Greeting + ", " + name } func AreaSquare(side float64) float64 { return side * side } func AreaRectangle(width, height float64) float64 { return width * height } func main() { fmt.Println(Greet("Ada", GreetOptions{})) fmt.Println(AreaSquare(3)) fmt.Println(AreaRectangle(3, 4)) }
Go has no overloading, no optional parameters and no named arguments. Two functions cannot share a name, so Area becomes AreaSquare and AreaRectangle — which the community regards as clearer, since the name now says which one you meant. Optional settings become an options struct, where the zero value stands for "not supplied"; the other common pattern is functional options, a variadic list of configuring functions. Both are more ceremony than Optional, and both survive the addition of a fourth option better.
⚠ No My namespace, and no desktop story
Everything has a counterpart in the standard library — but the shape of the application changes completely.
Option Strict On Imports System Module PlatformGotcha Sub Main() Console.WriteLine(Environment.MachineName.Length > 0) Console.WriteLine(IsNumeric("42")) Console.WriteLine(Now.Year > 2000) End Sub End Module
package main import ( "fmt" "os" "strconv" "time" ) func main() { name, _ := os.Hostname() fmt.Println(len(name) >= 0) _, err := strconv.Atoi("42") fmt.Println(err == nil) fmt.Println(time.Now().Year() > 2000) }
My.Computer.Name becomes os.Hostname(), My.Computer.FileSystem becomes os and path/filepath, IsNumeric becomes strconv.Atoi and an error check, Now becomes time.Now(). The larger point: Go has no desktop GUI story and no designer. A WinForms application does not port — it becomes an HTTP service (net/http is in the standard library and is genuinely production-grade) with a web front end, or a command-line tool. That is the decision to weigh before any of the syntax on this page matters.