Tuples and Anonymous Types

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

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

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example is a complete console program that starts with Option Strict On, was built with warnings treated as errors and was run in the .NET 10 SDK container (Linux); the output shown is real output. The examples that are meant to fail say so, and show the compiler messages that the SDK printed (with the file path removed). The project-level imports that the SDK adds (System, System.Collections.Generic, System.Linq and others) are why Console, List, Dictionary and the LINQ operators need no Imports. The shelf library catalogue provides the data: books with a title, a year and a kind. To keep each program self-contained, the examples declare a small Book class instead of referencing the full Book of the shelf domain model.

Both features answer the same need, a small group of related values without the ceremony of a named type, and they answer it in opposite ways. A tuple is a value type that can cross method boundaries: it is the right answer to "this function returns two things". An anonymous type is a compiler-generated class that has no name you can write: it is the right answer to "this query produces rows of a shape that only exists here". Knowing which one to pick, and where each one stops working, is most of what there is to learn. Visual Basic differs from C# in several places that matter for both: there is no deconstruction syntax, there are no = and <> operators for tuples, and anonymous types have value equality only for their Key properties.

Tuples

Why tuples exist

A method returns one value. Before Visual Basic 15 (Visual Studio 2017) the options for returning several were a ByRef parameter (the Try pattern), a purpose-built Structure or Class, or the System.Tuple classes, whose elements are called Item1, Item2 and so on, are read-only and are heap-allocated. Visual Basic 15 added tuple syntax over the System.ValueTuple structures, the same types that C# 7 uses, so a tuple declared in one language is an ordinary, fully typed value in the other. Visual Basic 15.3 added inferred element names, the counterpart of C# 7.1.

  • A tuple is an instance of System.ValueTuple(Of T1, T2, ...): a mutable structure with public fields. There is no allocation for the tuple itself.

  • Tuple support needs the ValueTuple type. It is part of the base class library on .NET (Core), .NET 5 and later, and .NET Framework 4.7 and later; for older targets add the System.ValueTuple NuGet package, otherwise the compiler reports that the predefined type ValueTuple(Of,,,) is not defined or imported.

  • The language version needs to be 15 or higher, and 15.3 for name inference. The default LangVersion of an SDK-style project on .NET 10 is 17.13, so nothing needs to be configured.

  • A tuple has between two and (in practice) seven elements. Longer tuples are supported through nesting, see Value semantics and mutability. There is no one-element tuple ((1) is a parenthesized expression) and an empty () is a syntax error (BC30201, "Expression expected").

Declaring tuples and naming their elements

A tuple literal is a comma-separated list of expressions in parentheses. Its type is inferred from the expressions; an element name is given either in the literal, with Name:=value, or in the declared type, with (Name As Type, ...), and Visual Basic 15.3 infers a name from a variable or member access when the literal does not give one.

Option Strict On

Public Class Book
    Public Property Title As String = ""
    Public Property Year As Integer
End Class

Module Program
    Sub Main()
        ' 1. Positional tuple: the elements are called Item1, Item2.
        Dim pair = (1, "Dune")
        Console.WriteLine(pair.Item1 & " " & pair.Item2)

        ' 2. Names given in the literal.
        Dim named = (Id:=2, Title:="Emma")
        Console.WriteLine(named.Id & " " & named.Title)

        ' 3. Names given in the declared type; the literal can be positional.
        Dim typed As (Id As Integer, Title As String) = (3, "Ulysses")
        Console.WriteLine(typed.Id & " " & typed.Title)

        ' 4. Names inferred from variables and from member accesses (Visual Basic 15.3).
        Dim id = 4
        Dim title = "Ivanhoe"
        Dim book As New Book() With {.Title = "Persuasion", .Year = 1817}
        Dim fromVariables = (id, title)
        Dim fromMembers = (book.Title, book.Year)
        Console.WriteLine(fromVariables.id & " " & fromVariables.title)
        Console.WriteLine(fromMembers.Title & " " & fromMembers.Year)

        ' The positional names always work, whatever the declared names are.
        Console.WriteLine(typed.Item2 & " " & fromMembers.Item2)

        ' A tuple is a ValueTuple, whose ToString lists the values.
        Console.WriteLine(named.ToString())
        Console.WriteLine(typed.GetType().Name & " is a value type: " & typed.GetType().IsValueType)
        Console.WriteLine(fromMembers)

        ' Elements are read-write fields.
        typed.Title = "Ulysses (1922)"
        Console.WriteLine(typed.Title)

        ' A nested tuple, and an array of tuples.
        Dim nested = (Outer:=1, Inner:=(P:=2, Q:=3))
        Console.WriteLine(nested.Inner.Q & " " & nested.Item2.Item1)
        Dim rows = {(1, "a"), (2, "b")}
        Console.WriteLine(rows.GetType().Name & " " & rows(1).Item2)
    End Sub
End Module
1 Dune
2 Emma
3 Ulysses
4 Ivanhoe
Persuasion 1817
Ulysses 1817
(2, Emma)
ValueTuple`2 is a value type: True
(Persuasion, 1817)
Ulysses (1922)
3 2
ValueTuple`2[] b

What the output shows:

  1. pair has only the positional names Item1 and Item2. Every tuple has them, whatever other names it carries, so typed.Item2 and typed.Title are the same field.

  2. Dim fromMembers = (book.Title, book.Year) produced elements called Title and Year: the inferred name is the last identifier of the expression. This is convenient for projections, but a refactoring that renames the property silently changes the tuple’s element names.

  3. The same literal can be typed as (Id As Integer, Title As String) on the left of As; the literal then needs no names, and the declared names win.

  4. ToString prints the values in parentheses and never the names: (2, Emma).

  5. The array rows has type ValueTuple(Of Integer, String)(): the element names are not part of the runtime type (see Conversions, names at run time and nullable tuples).

The ways of naming the elements:

Form Example Names of the elements

Positional literal

Dim p = (1, "Dune")

Item1, Item2

Named literal

Dim p = (Id:=1, Title:="Dune")

Id, Title (plus Item1, Item2)

Declared type

Dim p As (Id As Integer, Title As String) = (1, "Dune")

Id, Title

Inferred from names

Dim p = (id, title)

id, title (Visual Basic 15.3 and later)

Inferred from members

Dim p = (book.Title, book.Year)

Title, Year

Return type

Function F() As (Min As Integer, Max As Integer)

Min, Max for every caller

Generic argument

List(Of (Id As Integer, Title As String))

Id, Title for the whole collection

Name comparisons are case-insensitive, as everywhere in Visual Basic: typed.title finds Title. Names are metadata and convenience only; they never take part in type identity, equality or conversion.

Rules and diagnostics for element names

The compiler checks names where they are written.

Option Strict On

Module Program
    Sub Main()
        Dim twice = (A:=1, A:=2)
        Dim wrongPosition = (Item2:=1, Item1:=2)
        Dim retyped As (A As Integer, B As Integer) = (X:=1, Y:=2)
        Console.WriteLine(twice.ToString() & wrongPosition.ToString() & retyped.A)
    End Sub
End Module

The SDK reports:

Program.vb(5,28): error BC37262: Tuple element names must be unique.
Program.vb(6,30): error BC37261: Tuple element name 'Item2' is only allowed at position 2.
Program.vb(6,40): error BC37261: Tuple element name 'Item1' is only allowed at position 1.
Program.vb(7,56): error BC41009: The tuple element name 'X' is ignored because a different name or no name is specified by the target type '(A As Integer, B As Integer)'.
Program.vb(7,62): error BC41009: The tuple element name 'Y' is ignored because a different name or no name is specified by the target type '(A As Integer, B As Integer)'.
  • BC37262, "Tuple element names must be unique": two elements cannot have the same name.

  • BC37261, "Tuple element name 'Item2' is only allowed at position 2": the names ItemN are reserved for the element at position N, so a literal cannot call the first element Item2.

  • BC41009, "The tuple element name 'X' is ignored": a warning that appears when a literal assigned to a declared tuple type names an element differently. The declared names win and the literal’s names are dropped, which usually means a typing mistake. The examples of this section treat warnings as errors, which is why the listing says error; in a default project it is a warning.

Name inference is deliberately lenient: when the compiler cannot infer a name it does not report anything, it just leaves the element with its positional name. Inference does not happen when the candidate name is the same as a member of the tuple type (Item3, Rest, ToString) or when the same candidate appears twice in the literal. The symptom is a later BC30456 ("'dup' is not a member of '(Integer, Integer)'") at the use site, not at the literal.

Tuples as return values

The most common use of a tuple is the return value of a function. The element names are part of the declaration of the function and travel with it, so callers get named, IntelliSense-completed members without a named type.

Option Strict On

Imports System.Globalization

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

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

Module Program
    ' A named tuple as the return type.
    Function Summarize(books As IEnumerable(Of Book)) As (Count As Integer, Oldest As Integer, Newest As Integer)
        Dim years = books.Select(Function(b) b.Year).ToList()
        Return (years.Count, years.Min(), years.Max())
    End Function

    ' The Try pattern with a tuple instead of a ByRef parameter.
    Function ParseYear(text As String) As (Ok As Boolean, Year As Integer)
        Dim value As Integer
        Dim ok = Integer.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, value)
        Return (ok, value)
    End Function

    ' A tuple-returning iterator.
    Iterator Function Numbered(books As IEnumerable(Of Book)) As IEnumerable(Of (Position As Integer, Title As String))
        Dim position = 0
        For Each b In books
            position += 1
            Yield (position, b.Title)
        Next
    End Function

    Sub Main()
        Dim books = {New Book("Dune", 1965), New Book("Emma", 1815), New Book("Ulysses", 1922)}

        Dim s = Summarize(books)
        Console.WriteLine($"{s.Count} books from {s.Oldest} to {s.Newest}")

        For Each entry In {"1922", "nineteen"}
            Dim r = ParseYear(entry)
            Console.WriteLine($"{entry}: ok={r.Ok}, year={r.Year}")
        Next

        For Each n In Numbered(books)
            Console.WriteLine($"{n.Position}. {n.Title}")
        Next

        ' A lambda can return a tuple too; the element names come from the literal.
        Dim lengthOf = Function(b As Book) (b.Title, Length:=b.Title.Length)
        Console.WriteLine(lengthOf(books(2)))
        Console.WriteLine(lengthOf(books(2)).Length)
    End Sub
End Module
3 books from 1815 to 1965
1922: ok=True, year=1922
nineteen: ok=False, year=0
1. Dune
2. Emma
3. Ulysses
(Ulysses, 7)
7
  • Summarize returns three values with no helper type. The Return statement uses a plain tuple literal, converted to the declared type by position.

  • ParseYear is the Try pattern without the ByRef parameter. The advantage is that the caller declares nothing in advance and cannot forget to look at the result; the cost is that a failed parse still carries a meaningless 0.

  • An Iterator Function can Yield tuples (see Iterators and Yield). The element names come from the declared element type (Position As Integer, Title As String).

  • A lambda returns whatever its body returns, so Function(b As Book) (b.Title, Length:=b.Title.Length) is a function that returns (Title As String, Length As Integer), with the first name inferred and the second given. Lambdas are covered in Delegates and Lambda Expressions and procedures in Procedures: Sub and Function.

  • A public API that returns a tuple promises the element types and order to every caller. If the result is likely to grow members, behaviour or validation, return a named Structure or Class instead; a tuple cannot have methods or properties of its own.

No deconstruction syntax

C# can unpack a tuple in one statement, var (id, title) = book;, and in an assignment (id, title) = book;. Visual Basic 17.13 has neither form, and neither a For Each (key, value) In ... loop nor discards. Both attempts below are syntax errors.

Option Strict On

Module Program
    Sub Main()
        Dim book = (Id:=1, Title:="Dune")
        Dim (id, title) = book
        Console.WriteLine(id & title)
    End Sub
End Module

The SDK reports:

Program.vb(6,13): error BC30203: Identifier expected.
Option Strict On

Module Program
    Sub Main()
        Dim book = (Id:=1, Title:="Dune")
        Dim id As Integer
        Dim title As String
        (id, title) = book
        Console.WriteLine(id & title)
    End Sub
End Module

The SDK reports:

Program.vb(8,9): error BC30035: Syntax error.

BC30203 and BC30035 are the generic "Identifier expected" and "Syntax error" messages: the parser does not know the construct at all. The same is true of the Deconstruct methods that C# records, KeyValuePair and many library types provide: Visual Basic does not recognise them as a protocol. They remain ordinary methods, and the workarounds are all explicit.

Option Strict On

Module Program
    Sub Main()
        Dim book = (Id:=1, Title:="Dune")

        ' No deconstruction syntax: read the elements one by one.
        Dim id = book.Id
        Dim title = book.Title
        Console.WriteLine(id & " " & title)

        ' A With block avoids repeating the variable name.
        With book
            Console.WriteLine(.Id & " " & .Title)
        End With

        ' Library Deconstruct methods are ordinary methods in Visual Basic: call them yourself.
        Dim pair As New KeyValuePair(Of String, Integer)("Dune", 1965)
        Dim key As String = ""
        Dim value As Integer
        pair.Deconstruct(key, value)
        Console.WriteLine(key & " " & value)

        ' Iterating a dictionary gives KeyValuePair objects, not tuples.
        Dim years As New Dictionary(Of String, Integer) From {{"Dune", 1965}, {"Emma", 1815}}
        For Each entry In years
            Console.WriteLine(entry.Key & " " & entry.Value)
        Next

        ' Converting to tuples is a projection.
        For Each t In years.Select(Function(e) (Title:=e.Key, Year:=e.Value))
            Console.WriteLine(t.Title & " " & t.Year)
        Next
    End Sub
End Module
1 Dune
1 Dune
Dune 1965
Dune 1965
Emma 1815
Dune 1965
Emma 1815
  • Read each element into its own variable (Dim id = book.Id). With named elements this is no more verbose than deconstruction and it documents what each variable means.

  • A With block (Control Flow) lets you use .Id and .Title without repeating the tuple’s name.

  • pair.Deconstruct(key, value) is a real method call with ByRef parameters, so the variables must be declared first.

  • A Dictionary iterates as KeyValuePair structures, which have .Key and .Value. Project to tuples with Select only when you need the tuples (to sort them or to hand them to an API that wants tuples).

  • There is no pattern matching in Visual Basic, so tuple patterns such as (0, _) do not exist either. Use If and Select Case on the individual elements.

Equality, ordering and hashing

The ValueTuple types implement IEquatable(Of T), IComparable(Of T), IStructuralEquatable and IStructuralComparable, and Equals, CompareTo and GetHashCode work element by element. What Visual Basic lacks is the operators: in C# 7.3 and later a == b compares tuples; in Visual Basic the compiler has no = or <> for tuple operands.

Option Strict On

Module Program
    Sub Main()
        Dim a = (1, "Dune")
        Dim b = (1, "Dune")
        Console.WriteLine(a = b)
        Console.WriteLine(a <> b)
    End Sub
End Module

The SDK reports:

Program.vb(7,27): error BC30452: Operator '=' is not defined for types '(Integer, String)' and '(Integer, String)'.
Program.vb(8,27): error BC30452: Operator '<>' is not defined for types '(Integer, String)' and '(Integer, String)'.

BC30452, "Operator '=' is not defined for types …​", is the usual message for an operator with no operand support. Use Equals, or write the comparison out when you also need to ignore some elements.

Option Strict On

Module Program
    Sub Main()
        Dim a = (Id:=1, Title:="Dune")
        Dim b = (Id:=1, Title:="Dune")
        Dim c = (Id:=1, Title:="Emma")

        ' Equals compares element by element with each element's own Equals.
        Console.WriteLine(a.Equals(b) & " " & a.Equals(c))
        Console.WriteLine(a.GetHashCode() = b.GetHashCode())

        ' The names play no part: only position and types count.
        Dim other = (Key:=1, Name:="Dune")
        Console.WriteLine(a.Equals(other))

        ' The spelled-out comparison that the = operator would have been.
        Console.WriteLine((a.Id = c.Id) AndAlso (a.Title = c.Title))

        ' Equals(Object) needs identical element types: Integer is not Long.
        Console.WriteLine(a.Equals((CLng(1), "Dune")))

        ' IComparable: elements are compared left to right.
        Dim ordered = {(2, "Emma"), (1, "Ulysses"), (1, "Dune")}.OrderBy(Function(t) t).ToList()
        Console.WriteLine(String.Join(", ", ordered))
        Console.WriteLine(a.CompareTo(c))

        ' Tuples work as dictionary and HashSet keys.
        Dim copies As New Dictionary(Of (Isbn As String, Branch As String), Integer)
        copies((Isbn:="9780441172719", Branch:="North")) = 3
        copies((Isbn:="9780441172719", Branch:="South")) = 1
        Console.WriteLine(copies((Isbn:="9780441172719", Branch:="North")))
        Console.WriteLine(copies.ContainsKey(("9780441172719", "East")))

        Dim seen As New HashSet(Of (Integer, Integer)) From {(1, 2)}
        Console.WriteLine(seen.Contains((1, 2)) & " " & seen.Contains((2, 1)))
    End Sub
End Module
True False
True
True
False
False
(1, Dune), (1, Ulysses), (2, Emma)
-1
3
False
True False
  • a.Equals(b) compares the values, and a.Equals(other) is also True although the names differ (Id/Title against Key/Name): names are not part of equality.

  • Equals(Object) requires the same tuple type. (CLng(1), "Dune") is a ValueTuple(Of Long, String), so it is not equal to an (Integer, String) tuple that holds the same number. Convert the element, or compare the elements with =.

  • OrderBy(Function(t) t) and CompareTo use the default comparer of each element, from left to right. Strings use the culture’s comparison there; pass a comparer to OrderBy when the order must be ordinal.

  • Tuples are good composite keys for Dictionary and HashSet: they are structures, so there is no allocation per lookup, and the hash code combines the elements. The mutability caveat below applies: do not modify a tuple that is stored as a key.

Value semantics and mutability

A tuple is a structure with public mutable fields. Assignment copies it, ByVal passing copies it, and a copy never changes the original. That is what you want for a result value and a surprise for anyone who treats it like an object.

Option Strict On

Module Program
    Sub Rename(ByRef pair As (Id As Integer, Title As String))
        pair.Title = pair.Title.ToUpperInvariant()
    End Sub

    Sub Main()
        Dim original = (Id:=1, Title:="Dune")
        Dim copy = original
        copy.Title = "Emma"
        Console.WriteLine(original.Title & " " & copy.Title)

        ' ByRef changes the caller's tuple, ByVal does not.
        Rename(original)
        Console.WriteLine(original.Title)

        ' A list element is a value: copy it, change it, store it back.
        Dim list As New List(Of (Id As Integer, Title As String)) From {(1, "A"), (2, "B")}
        Dim item = list(0)
        item.Title = "Z"
        list(0) = item
        Console.WriteLine(list(0))

        ' An array element is a variable, so it can be changed in place.
        Dim array = {(Id:=1, Title:="A")}
        array(0).Title = "Y"
        Console.WriteLine(array(0))

        ' More than seven elements: the tenth is still Item10, stored in nested Rest tuples.
        Dim wide = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
        Console.WriteLine(wide.Item10 & " " & wide.GetType().Name & " " & wide.Rest.GetType().Name)
    End Sub
End Module
Dune Emma
DUNE
(1, Z)
(1, Y)
10 ValueTuple`8 ValueTuple`3
  • copy.Title = "Emma" did not touch original. Rename changed it only because the parameter is ByRef.

  • list(0).Title = "Z" does not compile, because list(0) is a value returned by the indexer, a copy, and changing a copy would be silently lost; the error is shown in the next section’s listing (BC30068). Copy the element, change it, and store it back. An array element, in contrast, is a variable, so array(0).Title = "Y" works.

  • The tenth element is still called Item10. The compiler stores elements beyond the seventh in a nested Rest field of type ValueTuple(Of ...), which is why the printed type is the ValueTuple with arity 8: seven elements plus Rest. Avoid tuples that long; a named type is clearer from about four elements.

  • Read-only is not available. If the value must not change, use a Structure with ReadOnly properties (see Structures and Value Types).

Conversions, names at run time and nullable tuples

Assignment between tuples is allowed when they have the same number of elements and each source element converts to the target element type. With Option Strict On (see Option Statements) that means a widening conversion, or an explicit CType for a narrowing one. Names are not part of the check.

Option Strict On

Imports System.Reflection

Module Program
    Function Range() As (Min As Integer, Max As Integer)
        Return (1, 9)
    End Function

    Sub Main()
        Dim named = (Answer:=42, Message:="The meaning of life")
        Dim differentNamed = (SecretConstant:=1, Label:="x")

        ' Same arity and convertible types: values are copied by position, names stay with the variable.
        named = differentNamed
        Console.WriteLine(named.Answer & " " & named.Message)

        ' Widening element conversion: (Integer, String) to (Long, String).
        Dim wider As (Long, String) = named
        Console.WriteLine(wider.Item1.GetType().Name)

        ' Narrowing needs an explicit conversion, and checks the range at run time.
        Dim big As (Long, String) = (3000000000L, "big")
        Try
            Dim narrow = CType(big, (Integer, String))
            Console.WriteLine(narrow)
        Catch ex As OverflowException
            Console.WriteLine("OverflowException")
        End Try

        ' Through Object the names are gone; DirectCast needs the exact element types.
        Dim boxed As Object = named
        Dim back = DirectCast(boxed, (Integer, String))
        Console.WriteLine(back.Item2)
        Console.WriteLine(TypeOf boxed Is ValueTuple(Of Integer, String))

        ' The names live in an attribute on the member that declares them.
        Dim method = GetType(Program).GetMethod("Range", BindingFlags.Static Or BindingFlags.Public Or BindingFlags.NonPublic)
        Dim data = method.ReturnParameter.GetCustomAttributesData().Single()
        Dim names = CType(data.ConstructorArguments(0).Value, IEnumerable(Of CustomAttributeTypedArgument))
        Console.WriteLine(data.AttributeType.Name & ": " & String.Join(", ", names.Select(Function(n) n.Value)))

        ' Nullable tuple.
        Dim maybe As (Min As Integer, Max As Integer)? = Nothing
        Console.WriteLine(maybe.HasValue)
        maybe = Range()
        Console.WriteLine(maybe.Value.Max)
    End Sub
End Module
1 x
Int64
OverflowException
x
True
TupleElementNamesAttribute: Min, Max
False
9
  • named = differentNamed copies the values by position. named keeps its own names, which is why named.Answer printed 1.

  • (Integer, String) converts to (Long, String) implicitly; the other direction needs CType and checks the value at run time (the OverflowException).

  • The runtime type of a tuple is just ValueTuple(Of ...). The names are stored in a TupleElementNamesAttribute on the member that declares them: here the return parameter of Range. Through Object, Dim boxed As Object = named, they are gone, and a cast back to a tuple type names the elements again.

  • Reflection and serializers see Item1, Item2; only the compiler and tools that read the attribute know the names.

  • (Min As Integer, Max As Integer)? is a Nullable(Of ValueTuple(Of ...)); read it with .Value or .HasValue (see Nothing and Nullable Value Types).

Conversions that are rejected:

Option Strict On

Module Program
    Sub Main()
        Dim pair = (1, "Dune")
        Dim narrower As (Integer, Integer) = pair

        Dim list As New List(Of (Id As Integer, Title As String)) From {(1, "A")}
        list(0).Title = "Z"

        Dim triple = (1, 2, 3)
        pair = triple
    End Sub
End Module

The SDK reports:

Program.vb(12,16): error BC30311: Value of type '(Integer, Integer, Integer)' cannot be converted to '(Integer, String)'.
Program.vb(6,46): error BC30512: Option Strict On disallows implicit conversions from '(Integer, String)' to '(Integer, Integer)'.
Program.vb(9,9): error BC30068: Expression is a value and therefore cannot be the target of an assignment.

BC30311 reports tuples of a different arity, BC30512 a narrowing conversion under Option Strict On, and BC30068 the attempt to modify a copy.

Tuples compared with the alternatives

Feature Tuple (ValueTuple) System.Tuple Structure Class Anonymous type

Kind

structure

class

structure

class

class (generated)

Allocation

none for the tuple

heap

none

heap

heap

Mutable

yes, always

no

your choice

your choice

only non-Key properties

Names usable in a signature

yes, (A As Integer, B As String)

no, Item1 …​

yes

yes

no

Own members

no

no

yes

yes

no (properties only)

= and <>

not defined

not defined

if you overload them

if you overload them

not defined

Value equality

yes, Equals

yes, Equals

you implement it

you implement it

only with Key properties

Deconstruction

no

no

no

no

no

Introduced in VB

15 (2017)

.NET Framework 4.0

all versions

all versions

9.0 (2008)

Choose a tuple for a short-lived result of two to four values whose meaning is clear from the names. Choose a named type when the data has behaviour, invariants or a life beyond the call. System.Tuple survives in older APIs; there is no reason to write it in new code, and Tuple.Create(1, "x") is a reference type that cannot be mutated.

Anonymous types

Creating an anonymous type

An anonymous type is declared by an object initializer that has no type name: New With { ... }. The compiler generates a class with the properties you list, infers each property’s type from its expression, and gives the class a name you cannot write. They were introduced in Visual Basic 9.0 (Visual Studio 2008) together with LINQ, for which they were designed.

Option Strict On

Public Class Book
    Public Property Title As String = ""
    Public Property Year As Integer
End Class

Module Program
    Sub Main()
        Dim book As New Book() With {.Title = "Dune", .Year = 1965}

        ' Explicit names, with and without Key.
        Dim plain = New With {.Id = 1, .Title = "Dune"}
        Dim keyed = New With {Key .Id = 1, Key .Title = "Dune"}
        Console.WriteLine(plain)
        Console.WriteLine(keyed)

        ' Names and types inferred from the expressions (Visual Basic 9.0).
        Dim inferred = New With {book.Title, book.Year, Key .Decade = (book.Year \ 10) * 10}
        Console.WriteLine(inferred)
        Console.WriteLine(inferred.Title & " " & inferred.Decade)

        ' Non-Key properties can be assigned; Key properties are read-only.
        plain.Title = "Dune Messiah"
        Console.WriteLine(plain.Title)

        ' Anonymous types can nest and can hold arrays and lambdas.
        Dim richer = New With {.Author = New With {.Name = "Frank Herbert"}, .Years = {1965, 1969}, .Greet = Function(s As String) "Hello " & s}
        Console.WriteLine(richer.Author.Name & " " & richer.Years.Length & " " & richer.Greet("reader"))

        ' The type is generated: internal, sealed, generic and without a usable name.
        Dim t = keyed.GetType()
        Console.WriteLine(t.Name.StartsWith("VB$AnonymousType_") & " " & t.IsGenericType & " " & t.IsSealed & " " & t.IsPublic)
        Console.WriteLine(t.BaseType.Name)
    End Sub
End Module
{ Id = 1, Title = Dune }
{ Id = 1, Title = Dune }
{ Title = Dune, Year = 1965, Decade = 1960 }
Dune 1960
Dune Messiah
Frank Herbert 2 Hello reader
True True True False
Object
  • Each member is .Name = expression. The name may be omitted when the expression is a simple name or a member access, and then the last identifier becomes the property name (book.Title gives Title). Where inference cannot produce a name, you must give one: New With {1 + 2} is BC36556 ("can be inferred only from a simple or qualified name with no arguments"). Names must be unique without regard to case, and may not be Equals, GetHashCode or ToString (all BC36547), because the generated type has those methods.

  • The declaration uses local type inference: the variable has no As clause, so Option Infer On (the default in a new project) is required. With Option Infer Off and Option Strict On, Dim a = New With {.X = 1} is BC30209, "requires all variable declarations to have an 'As' clause", and the only way to hold the value is an Object variable. See Option Statements.

  • The properties can be of any type: other anonymous types, arrays, lambdas, collections.

  • The generated class derives directly from Object, is sealed, is generic (its type parameters are the property types, which is why IsGenericType is True) and is not public. Its name starts with VB$AnonymousType_ followed by a number and the arity, and it depends on the compilation. Do not use it, parse it, or store it: Learn says the name may change whenever the project is recompiled.

  • The properties are read-write unless marked Key.

Key properties, equality and hashing

Marking a property with Key does three things: the property becomes read-only, it takes part in Equals and GetHashCode, and (if at least one property is a key) the compiler also implements IEquatable(Of T).

Option Strict On

Module Program
    Sub Main()
        Dim keyed = New With {Key .Id = 1, Key .Title = "Dune"}
        keyed.Id = 2
    End Sub
End Module

The SDK reports:

Program.vb(6,9): error BC30526: Property 'Id' is 'ReadOnly'.
Option Strict On

Module Program
    Sub Main()
        Dim a1 = New With {Key .Id = 1, Key .Title = "Dune"}
        Dim a2 = New With {Key .Id = 1, Key .Title = "Dune"}
        Dim a3 = New With {Key .Id = 1, Key .Title = "Emma"}
        Console.WriteLine("Key: " & a1.Equals(a2) & " " & a1.Equals(a3) & " hash equal: " & (a1.GetHashCode() = a2.GetHashCode()))

        Dim p1 = New With {.Id = 1, .Title = "Dune"}
        Dim p2 = New With {.Id = 1, .Title = "Dune"}
        Console.WriteLine("no Key: " & p1.Equals(p2) & " " & p1.Equals(p1))

        ' Only the Key properties take part in equality.
        Dim m1 = New With {Key .Id = 1, .Note = "first"}
        Dim m2 = New With {Key .Id = 1, .Note = "second"}
        Console.WriteLine("mixed: " & m1.Equals(m2))

        ' What the compiler generated.
        Console.WriteLine("Key interfaces: " & String.Join(",", a1.GetType().GetInterfaces().Select(Function(i) i.Name)))
        Console.WriteLine("no-Key interfaces: " & p1.GetType().GetInterfaces().Length)
        Console.WriteLine(a1.GetType().GetMethod("Equals", {GetType(Object)}).DeclaringType Is a1.GetType())
        Console.WriteLine(p1.GetType().GetMethod("Equals", {GetType(Object)}).DeclaringType Is p1.GetType())

        ' Type identity: names (case-insensitive), order, types and Key must all match.
        Dim same = New With {Key .title = "x", Key .ID = 2}
        Dim sameShape = New With {Key .Title = "y", Key .Id = 3}
        Dim reordered = New With {Key .Id = 3, Key .Title = "y"}
        Dim differentKey = New With {Key .Title = "y", .Id = 3}
        Dim differentType = New With {Key .Title = "y", Key .Id = 3L}
        Console.WriteLine(same.GetType() Is sameShape.GetType())
        Console.WriteLine(sameShape.GetType() Is reordered.GetType())
        Console.WriteLine(sameShape.GetType() Is differentKey.GetType())
        Console.WriteLine(sameShape.GetType() Is differentType.GetType())

        ' Equals(Object) between different anonymous types is False, never an error.
        Console.WriteLine(sameShape.Equals(reordered))

        ' Use as keys of hashed collections.
        Dim set1 As New HashSet(Of Object) From {a1, a2, a3}
        Console.WriteLine(set1.Count)
    End Sub
End Module
Key: True False hash equal: True
no Key: False True
mixed: True
Key interfaces: IEquatable`1
no-Key interfaces: 0
True
False
True
False
False
False
False
2
The anonymous type has Equals and GetHashCode Generated members

No Key property

inherited from Object: two instances are equal only if they are the same object

ToString only

One or more Key properties

element-wise over the key properties; non-key properties are ignored

Equals, GetHashCode, ToString and IEquatable(Of T)

  • a1.Equals(a2) is True: same type, equal keys. The two hash codes are equal, as the Equals contract requires, so the instances behave as one value in a HashSet (set1.Count is 2: a1 and a2 are one value, a3 another).

  • Without Key, p1.Equals(p2) is False although every value is the same. Only p1.Equals(p1) is True. This is the opposite of the C# behaviour, where every anonymous type has value equality; a Visual Basic program that relies on value equality must write Key.

  • Equals(Object) between instances of different anonymous types returns False and never throws. There is no = operator for anonymous types either (BC30452).

  • ToString prints { Id = 1, Title = Dune } for every anonymous type, with or without Key. It is convenient for logging and must not be parsed.

  • A key property whose value is mutable (an array, a list) still compares by that object’s own Equals, which is reference equality for most collections.

When two anonymous types are the same type

The compiler reuses one generated class for every declaration of the same shape within an assembly. Same shape means: the same property names (compared without regard to case), in the same order, with the same inferred types, and with the same properties marked Key.

The program above compared four pairs of declarations with GetType; the result is in the table.

Declarations Same type?

Key .title, Key .ID and Key .Title, Key .Id (different case)

yes

Key .Title, Key .Id and Key .Id, Key .Title (different order)

no

Key .Title, Key .Id and Key .Title, .Id (different Key)

no

Key .Id of type Integer and Key .Id of type Long (different type)

no

The same shape declared in two assemblies

no: each assembly has its own generated class (Learn, not tested here)

Type identity matters in three places: Equals, assignment (a variable of one anonymous type cannot hold another: BC30311), and generic inference, for instance when you Add to a List of an anonymous type. A one-letter slip in a property name quietly produces a new type; a named Class or Structure would have been rejected by the compiler.

Anonymous types in query expressions

Anonymous types were built for LINQ. A Select that names several expressions creates one, and in a query expression the properties of the generated type are Key properties automatically, so projection results already have value equality. Query syntax is described in LINQ Query Expressions.

Option Strict On

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

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

Public Class Loan
    Public ReadOnly Property Title As String
    Public ReadOnly Property Borrower As String

    Public Sub New(title As String, borrower As String)
        Me.Title = title
        Me.Borrower = borrower
    End Sub
End Class

Module Program
    Sub Main()
        Dim items = {
            New Book("Dune", 1965, "Novel"),
            New Book("Emma", 1815, "Novel"),
            New Book("Nature", 1965, "Magazine"),
            New Book("Ulysses", 1922, "Novel"),
            New Book("Wired", 1965, "Magazine")}
        Dim loans = {New Loan("Dune", "Ana"), New Loan("Wired", "Luis"), New Loan("Dune", "Marta")}

        ' 1. Projection: with several expressions, Select builds an anonymous type with Key properties.
        Dim projection = From b In items
                         Where b.Year > 1900
                         Order By b.Title
                         Select b.Title, b.Year
        For Each p In projection
            Console.WriteLine($"{p.Title} ({p.Year})")
        Next

        ' 2. Renaming and computing members.
        Dim labels = From b In items
                     Select Label = b.Kind & ": " & b.Title, Age = 2026 - b.Year
        Console.WriteLine(labels.First())

        ' 3. Composite grouping key: it must be a Key anonymous type (or a tuple).
        Dim groups = From b In items
                     Group By k = New With {Key b.Year, Key b.Kind} Into Count()
                     Order By k.Year, k.Kind
        For Each g In groups
            Console.WriteLine($"{g.k.Year} {g.k.Kind}: {g.Count}")
        Next
        Dim brokenGroups = From b In items
                           Group By k = New With {b.Year, b.Kind} Into Count()
        Console.WriteLine("without Key: " & brokenGroups.Count() & " groups instead of " & groups.Count())

        ' 4. Join on a composite key.
        Dim borrowed = From b In items
                       Join l In loans On New With {Key b.Title} Equals New With {Key l.Title}
                       Select b.Title, l.Borrower
        Console.WriteLine(String.Join("; ", borrowed.Select(Function(r) r.Title & "->" & r.Borrower)))
        Dim unmatched = From b In items
                        Join l In loans On New With {b.Title} Equals New With {l.Title}
                        Select b.Title, l.Borrower
        Console.WriteLine("without Key: " & unmatched.Count() & " matches")

        ' 5. Distinct only collapses anonymous values when they have Key properties.
        Console.WriteLine(items.Select(Function(b) New With {Key b.Kind}).Distinct().Count())
        Console.WriteLine(items.Select(Function(b) New With {b.Kind}).Distinct().Count())

        ' 6. Method syntax, and a List of the generated type.
        Dim list = items.Select(Function(b) New With {Key b.Title, .Initial = b.Title(0)}).ToList()
        list.Add(New With {Key .Title = "Extra", .Initial = "E"c})
        Console.WriteLine(list.Count & " " & list(5).ToString())
    End Sub
End Module
Dune (1965)
Nature (1965)
Ulysses (1922)
Wired (1965)
{ Label = Novel: Dune, Age = 61 }
1815 Novel: 1
1922 Novel: 1
1965 Magazine: 2
1965 Novel: 1
without Key: 5 groups instead of 4
Dune->Ana; Dune->Marta; Wired->Luis
without Key: 0 matches
2
5
6 { Title = Extra, Initial = E }

Source: Select Clause

  • Select b.Title, b.Year projects an anonymous type whose properties are called Title and Year; Select Label = ..., Age = ... names computed members. When a query selects one expression or the whole range variable it does not need an anonymous type.

  • Group By k = New With {Key b.Year, Key b.Kind} groups by a composite key. The grouping compares keys with Equals, so the key must have value equality: Key properties (or a tuple, Group By k = (b.Year, b.Kind), which also works). Without Key, every book became its own group: 5 groups instead of 4, and the error is silent.

  • The same rule applies to Join ... On New With {...} Equals New With {...}: both sides must be the same anonymous type, so the property names must match (Title on both sides) and be Key. Without Key, the join matched nothing (0 matches) and no diagnostic was produced. Single-expression joins, On b.Title Equals l.Title, need none of this; see LINQ Query Expressions.

  • Distinct collapses anonymous values only if they have Key properties: 2 kinds with Key, 5 books without.

  • Select(Function(b) New With {Key b.Title, .Initial = b.Title(0)}) is the method-syntax form. ToList gives a List of the generated class and the compiler lets you Add another instance of the same shape.

Query results of anonymous types are read-only from outside the query: a projection is a view, not a copy of the source rows. Writing back to the source takes the original object (Select b or Select Item = b, ...).

Limits and workarounds

An anonymous type has no name, so there is no place in the language where you can write its type: not a field, a property, a parameter or a return type.

Option Strict On

Module Program
    Private ReadOnly Origin = New With {.X = 0, .Y = 0}

    Sub Main()
        Console.WriteLine(Origin)
    End Sub
End Module

The SDK reports:

Program.vb(4,22): error BC30209: Option Strict On requires all variable declarations to have an 'As' clause.
Option Strict On

Module Program
    Function MakeItem() As Object
        Return New With {.Title = "Dune", .Year = 1965}
    End Function

    Sub Main()
        Dim item = MakeItem()
        Console.WriteLine(item.Title)
    End Sub
End Module

The SDK reports:

Program.vb(10,27): error BC30574: Option Strict On disallows late binding.
  • A field needs an As clause under Option Strict On (BC30209), and no clause can name the type.

  • Returning the value as Object works, but item.Title is then late binding (BC30574 under Option Strict On). Without Option Strict On the same code compiles and fails with MissingMemberException at run time when a name is wrong. See Early and Late Binding.

  • So anonymous types live inside one method. When the shape must leave it, change to a tuple (pass the shape across methods) or a named type. When the data stays inside the method but is processed by a helper, a generic method can accept it:

Option Strict On

Module Program
    ' Generic inference lets a helper accept an anonymous type without naming it.
    Function Describe(Of T)(value As T) As String
        Return "anonymous=" & GetType(T).Name.StartsWith("VB$AnonymousType_") & " -> " & value.ToString()
    End Function

    ' A lambda keeps the anonymous type in its return type, inside one method.
    Function Project(Of T, TResult)(source As IEnumerable(Of T), selector As Func(Of T, TResult)) As List(Of TResult)
        Return source.Select(selector).ToList()
    End Function

    ' The "cast by example" trick: recover the type from a sample of the same shape.
    Function CastByExample(Of T)(value As Object, example As T) As T
        Return DirectCast(value, T)
    End Function

    Function Boxed() As Object
        Return New With {Key .Id = 7, Key .Title = "Dune"}
    End Function

    Sub Main()
        Console.WriteLine(Describe(New With {Key .Id = 1}))

        Dim rows = Project({"Dune", "Emma"}, Function(s) New With {Key .Title = s, .Length = s.Length})
        Console.WriteLine(rows(1).Title & " " & rows(1).Length)

        Dim sample = New With {Key .Id = 0, Key .Title = ""}
        Dim back = CastByExample(Boxed(), sample)
        Console.WriteLine(back.Id & " " & back.Title)
    End Sub
End Module
anonymous=True -> { Id = 1 }
Emma 4
7 Dune
  • Generic type inference (Describe(Of T), Project(Of T, TResult)) lets the compiler pass the unnameable type around without you naming it, and the lambda’s return type becomes TResult. This is how LINQ itself works.

  • CastByExample recovers the static type from a sample value of the same shape (sample), because the sample and the boxed value are instances of one generated class. It is a well-known trick and a fragile one: it compiles whatever the shapes are, and a mismatch throws InvalidCastException at run time. Prefer a tuple or a named type to this.

Choosing between tuples, anonymous types and named types

flowchart TD S["A small group of values"] --> Q1{"Does the shape leave the method?"} Q1 -->|"yes"| Q2{"Behaviour, validation or a long life?"} Q2 -->|"yes"| T1["Structure or Class"] Q2 -->|"no"| T2["Tuple with named elements"] Q1 -->|"no"| Q3{"Is it a query projection or a grouping key?"} Q3 -->|"yes"| T3["Anonymous type with Key properties"] Q3 -->|"no"| Q4{"Do you need to name the type?"} Q4 -->|"yes"| T2 Q4 -->|"no"| T3
Situation Choice

A function returns two to four related values

named tuple

A Try method without ByRef

tuple (Ok As Boolean, Value As T)

LINQ projection used in the same method

anonymous type (automatically Key in query syntax)

Composite key for Group By, Join, Distinct

anonymous type with Key properties, or a tuple

Composite key of a Dictionary or HashSet

tuple; an anonymous type cannot be named in Dictionary(Of K, V)

Result stored in a field or passed between classes

tuple, or better a Structure/Class

Needs methods, validation, immutability or serialization shape

Structure or Class

Value object with equality semantics for a domain concept

named Structure (see the shelf Isbn in Operator Overloading and Equality)

Serialization, reflection and interoperability

Option Strict On

Imports System.Text.Json

Module Program
    Sub Main()
        Dim anonymous = New With {.Title = "Dune", .Year = 1965}
        Console.WriteLine(JsonSerializer.Serialize(anonymous))

        Dim pair = (Title:="Dune", Year:=1965)
        Console.WriteLine(JsonSerializer.Serialize(pair))

        Dim options As New JsonSerializerOptions() With {.IncludeFields = True}
        Console.WriteLine(JsonSerializer.Serialize(pair, options))

        ' Deserializing needs a type name: anonymous types have none, tuples ignore names.
        Dim back = JsonSerializer.Deserialize(Of (Title As String, Year As Integer))("{""Item1"":""Dune"",""Item2"":1965}", options)
        Console.WriteLine(back)
    End Sub
End Module
{"Title":"Dune","Year":1965}
{}
{"Item1":"Dune","Item2":1965}
(Dune, 1965)
  • System.Text.Json serializes the public properties of an anonymous type, so a projection can be written straight to a response body. It cannot deserialize into one, since no type name can be passed.

  • Tuple elements are public fields, and the serializer ignores fields by default, so a tuple serializes as the empty object {}. Set IncludeFields = True and the elements appear as Item1 and Item2; the element names are not available at run time (they live in the attribute), so they never appear in the JSON. For wire formats use a Class or Structure with properties (see Serialization: JSON and XML).

  • A source-generated serializer context declares its types by name, so it cannot list an anonymous type; with trimming or native AOT prefer named types for anything that is serialized.

  • Another .NET language sees the same types: a Visual Basic function returning (Min As Integer, Max As Integer) is ValueTuple<int, int> for C# and F#, and the C# compiler reads TupleElementNamesAttribute to show the names again. An anonymous type is internal to its assembly and has no meaning outside it. See Interop with C# and Other .NET Languages.

  • On .NET Framework before 4.7 the System.ValueTuple package must be referenced by every project that exposes a tuple.

Differences from C#

Topic Visual Basic 17.13 C# (see Tuples, Deconstruction and Anonymous Types)

Tuple literal

(Id:=1, Title:="Dune"), (Id As Integer, Title As String)

(Id: 1, Title: "Dune"), (int Id, string Title)

Deconstruction

none: no Dim (a, b) = t, no Deconstruct support

var (a, b) = t;, (a, b) = t;, discards

Tuple = and <>

not defined (BC30452); use Equals

== and != since C# 7.3

Name inference

Visual Basic 15.3

C# 7.1

Pattern matching on tuples

none

tuple patterns in switch

Anonymous type syntax

New With {.A = 1}, Key on properties

new { A = 1 }

Mutability of anonymous types

non-Key properties are read-write

always read-only

Anonymous type equality

value equality only for Key properties

always value equality

Query projections

automatically Key properties

read-only value-equal anonymous type

Common errors and pitfalls

Symptom Cause and fix

BC30203, BC30035 for Dim (a, b) = t or (a, b) = t

Visual Basic has no deconstruction; read the elements by name

BC30452 on t1 = t2 for tuples

no =/<> for tuples or anonymous types; use Equals or compare the elements

BC30452 for "x" & t

& is not defined for tuples and anonymous types; use t.ToString() or an interpolated string

BC30068 on list(0).Item1 = ...

the indexer returns a copy; copy, change, store back (or use an array)

BC41009, BC37261, BC37262

a tuple element name is dropped, misplaced or duplicated

BC30311, BC30512

tuple of another arity, or a narrowing element conversion under Option Strict On

BC30526 on an anonymous type property

the property is a Key property: read-only

BC30209 or BC30574 around an anonymous type

no As clause can name it; keep it in one method, or use a tuple or a named type

BC36547, BC36556

duplicate or reserved property name; or a member whose name cannot be inferred

Group By or Join returns too many groups or no rows

the anonymous key type has no Key properties, so it compares by reference

A tuple prints as {} in JSON

tuple elements are fields; set IncludeFields or use a named type

Stored tuple changes after being used as a dictionary key

tuples are mutable: never modify a key

Older material you may meet

Books and snippets written before 2017 predate the tuple syntax and show the pre-tuple techniques; they still compile, but newer code does not need them.

  • Tuple.Create(a, b) and Dim t As Tuple(Of Integer, String): the System.Tuple classes, read-only reference objects with Item1 and Item2. Today ValueTuple literals replace them, and conversions between the two families need explicit code.

  • ByRef output parameters, Structure types made for a single method, and returning an Object() array or a Collection of boxed values. VB6 and VBA code returned several results through ByRef arguments or Variant arrays.

  • Material from the Visual Basic 2008 to 2015 era describes anonymous types and Key correctly, but it predates tuples, so it uses an anonymous type or a Structure where a named tuple is now the lightest option, and it cannot mention name inference (15.3).

  • Query-syntax Select creates anonymous types whose properties are all Key properties, while an explicit New With without Key does not. A snippet that moves a projection from query syntax to an explicit New With can therefore change the behaviour of Distinct, Group By, Join and Equals.