In the previous lesson you got Book and Magazine to share, through LibraryItem, the properties and methods common to any library material. But for now you're still treating them separately: if you wanted to show the description of every item in the catalog, you'd need two separate loops — one for books and another for magazines — or a series of manual type checks. Polymorphism (from the Greek for "many forms") is the ability to treat objects of different classes that inherit from the same base uniformly, letting each one behave according to its own real type at runtime. In this lesson you'll learn to walk through BiblioTech's entire catalog — books and magazines mixed together — with a single loop, and each item will "know" how to describe itself correctly.

Contents

  1. Two kinds of polymorphism: a recap of what you've already seen
  2. Runtime polymorphism: the Describe() method
  3. Dynamic binding: who decides which version runs
  4. A mixed catalog: LibraryItem[] traversed polymorphically
  5. The is operator: checking an object's real type
  6. The as operator: casting safely
  7. Application: filtering the catalog by specific type

  1. Two kinds of polymorphism: a recap of what you've already seen

In C# there are two forms of polymorphism:

Kind of polymorphism When it's decided which code runs Where you saw it
Compile-time (static) The compiler, based on the number/type of arguments Method overloading, "Methods" lesson
Runtime (dynamic) The program, while running, based on the object's real type virtual/override, this lesson's topic

You already used the first one without calling it by name: when you defined ShowDetails() and ShowDetails(bool includeIsbn) in the Methods lesson, the compiler decided, at compile time, which of the two versions to invoke, based on how many arguments you passed it. This lesson focuses on the second kind, much more characteristic of object-oriented programming.

  1. Runtime polymorphism: the Describe() method

In the previous lesson, ShowDetails() was already virtual in LibraryItem and was overridden in Book and Magazine. Let's add a second virtual method, Describe(), designed specifically to illustrate polymorphism in action: instead of printing directly to the console, it returns a string with a short description of the item, different depending on its specific type.

class LibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }
    public bool Available { get; set; } = true;

    public LibraryItem(string title, string author)
    {
        Title = title;
        Author = author;
    }

    public virtual string Describe()
    {
        return $"Item: {Title}, by {Author}";
    }
}

class Book : LibraryItem
{
    public string Isbn { get; set; }

    public Book(string title, string author, string isbn) : base(title, author)
    {
        Isbn = isbn;
    }

    public override string Describe()
    {
        return $"Book: {Title}, by {Author} (ISBN {Isbn})";
    }
}

class Magazine : LibraryItem
{
    public int IssueNumber { get; set; }

    public Magazine(string title, string author, int issueNumber) : base(title, author)
    {
        IssueNumber = issueNumber;
    }

    public override string Describe()
    {
        return $"Magazine: {Title}, issue number {IssueNumber}";
    }
}

Each class offers its own version of Describe(), tailored to what makes sense to show for that specific type of item.

  1. Dynamic binding: who decides which version runs

Now comes the key part of polymorphism. Look at this code:

LibraryItem item = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Console.WriteLine(item.Describe());
// Book: Hopscotch, by Julio Cortazar (ISBN 978-84-376-0495-4)

The item variable is declared as type LibraryItem, but the object it points to is actually a Book. When calling item.Describe(), C# doesn't run the LibraryItem version (which would be expected if only the variable's type were considered), but rather the version overridden in Book, because that's the object's real type in memory. This mechanism is called dynamic binding (or late binding): which code runs is decided at runtime, based on the object's real type, not on the declared type of the variable referencing it.

This only happens because Describe() is virtual in the base class and override in the inheriting one; if Describe() weren't virtual, item.Describe() would always run the LibraryItem version, regardless of which specific object the variable actually held.

  1. A mixed catalog: LibraryItem[] traversed polymorphically

This is where polymorphism shows its true usefulness: an array (or any collection) declared with the base type can contain objects of any of its inheriting classes, mixed together, and be traversed with a single loop:

LibraryItem[] catalog = new LibraryItem[]
{
    new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"),
    new Magazine("National Geographic", "Various authors", 302),
    new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1")
};

foreach (LibraryItem item in catalog)
{
    Console.WriteLine(item.Describe());
}

Output:

Book: Hopscotch, by Julio Cortazar (ISBN 978-84-376-0495-4)
Magazine: National Geographic, issue number 302
Book: Ficciones, by Jorge Luis Borges (ISBN 978-84-376-0496-1)

A single foreach loop, written just once over the base type LibraryItem, automatically produces the correct description for each element, whether Book or Magazine, without the loop needing to know anything about those specific types or containing any explicit type check. Adding a third inheriting class tomorrow (for example, a future Dvd : LibraryItem) wouldn't require touching this loop at all: it would be enough for the new class to override Describe() in its own way. This is the essence of polymorphism: the code that traverses the catalog stays stable even as the catalog grows in types.

  1. The is operator: checking an object's real type

Sometimes, however, you need to do something specific to a particular type, not just what the base class offers. The is operator checks whether an object is (or derives from) a given type, and optionally assigns it directly to a variable of that more specific type:

foreach (LibraryItem item in catalog)
{
    if (item is Book book)
    {
        Console.WriteLine($"It's a book; its ISBN is {book.Isbn}");
    }
    else if (item is Magazine magazine)
    {
        Console.WriteLine($"It's a magazine; its issue number is {magazine.IssueNumber}");
    }
}

item is Book book reads as "if item is (at runtime) a Book, then assign that same reference, now typed as Book, to a new variable called book." Inside the if block, book already gives access to Isbn, a property that doesn't exist in LibraryItem and that, therefore, wouldn't be directly accessible through the item variable. This pattern is called pattern matching with is, and it will be expanded considerably in Module 4 ("Pattern Matching and Modern Features").

  1. The as operator: casting safely

The as operator attempts to convert (cast) an object to a more specific type, and returns null if the conversion isn't possible, instead of throwing an exception:

LibraryItem firstElement = catalog[0];

Book? castBook = firstElement as Book;

if (castBook != null)
{
    Console.WriteLine($"Cast succeeded: {castBook.Isbn}");
}
else
{
    Console.WriteLine("The element wasn't a Book.");
}

If firstElement were actually a Magazine, firstElement as Book wouldn't throw any error: castBook would simply be null, and the code can check for that safely. This contrasts with a direct cast in parentheses, like (Book) firstElement, which, if the object isn't really a Book, does throw an exception (InvalidCastException) at runtime. In modern C# practice, is with a pattern (previous section) is usually preferred over as for this kind of check, for being more compact and readable, but it's worth knowing both.

Operator/technique If the type doesn't match When to use it
(Book) item (direct cast) Throws InvalidCastException When you're sure of the type and an error would be a genuine sign of failure
item as Book Returns null When the conversion might reasonably fail and you'd rather check for null
item is Book book The if condition is false (doesn't enter the block) The most readable form in modern C#; combines checking and assignment in one step

  1. Application: filtering the catalog by specific type

We'll close the lesson by combining polymorphic traversal with is, to count how many elements of the catalog are books and how many are magazines — a common operation when generating statistics for BiblioTech's catalog:

LibraryItem[] catalog = new LibraryItem[]
{
    new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"),
    new Magazine("National Geographic", "Various authors", 302),
    new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"),
    new Magazine("Muy Interesante", "Various authors", 487)
};

int totalBooks = 0;
int totalMagazines = 0;

foreach (LibraryItem item in catalog)
{
    Console.WriteLine(item.Describe()); // polymorphism: each one describes itself its own way

    if (item is Book)
    {
        totalBooks++;
    }
    else if (item is Magazine)
    {
        totalMagazines++;
    }
}

Console.WriteLine($"Total books: {totalBooks}");
Console.WriteLine($"Total magazines: {totalMagazines}");

Note that in if (item is Book), without declaring an additional variable, it's enough to ask "is this object a Book?" without needing to access any of its specific properties — useful when you only care about counting or classifying, not operating on the specific type's own data.

Common Mistakes and Tips

  • Forgetting virtual in the base class: if Describe() isn't virtual, writing override in Book or Magazine causes a compilation error; and if you instead use the new keyword to "hide" the method (rather than overriding it), you'll lose polymorphism: item.Describe() would go back to always running the LibraryItem version, even if the real object were a Book.
  • Casting directly without checking the type: (Book) item without checking first with is can throw InvalidCastException if the real object isn't a Book; prefer is with a pattern or as when you're not sure of the real type.
  • Writing a separate loop for each inheriting type: if you catch yourself writing a foreach for books and another nearly identical one for magazines, that's a sign you should be traversing the base type (LibraryItem[]) with a single polymorphic loop, as in section 4.
  • Confusing the variable's type with the object's real type: a variable declared as LibraryItem can point to a Book or a Magazine; what determines which version of a virtual method runs is always the object's real type, never the type the variable was declared with.
  • Tip: before resorting to is/as to distinguish types inside a loop, ask yourself whether that different behavior couldn't simply be solved by overriding a virtual method in each inheriting class; it's usually a cleaner, more extensible design.

Exercises

  1. Using the LibraryItem, Book and Magazine classes defined in this lesson (with Describe() as a virtual/override method), create a LibraryItem[] array with two books and one magazine, and traverse it with foreach, printing the result of Describe() for each one.

  2. On the same array from the previous exercise, use the is operator with a pattern to show, only for the elements that are Book, a message with its ISBN (for example: "ISBN: 978-84-376-0495-4").

  3. Write an expression using the as operator that tries to cast the array's first element to Magazine, and shows "It's a magazine with issue number <number>" if the cast succeeds, or "It's not a magazine" if the result is null.

Solutions

LibraryItem[] catalog = new LibraryItem[]
{
    new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"),
    new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"),
    new Magazine("National Geographic", "Various authors", 302)
};

foreach (LibraryItem item in catalog)
{
    Console.WriteLine(item.Describe());
}
foreach (LibraryItem item in catalog)
{
    if (item is Book book)
    {
        Console.WriteLine($"ISBN: {book.Isbn}");
    }
}
Magazine? castMagazine = catalog[0] as Magazine;

if (castMagazine != null)
{
    Console.WriteLine($"It's a magazine with issue number {castMagazine.IssueNumber}");
}
else
{
    Console.WriteLine("It's not a magazine");
}

Since catalog[0] is actually a Book (according to exercise 1), the cast with as returns null, and the message shown is "It's not a magazine".

Conclusion

In this lesson you've seen runtime polymorphism in action: a virtual method like Describe(), overridden differently in Book and Magazine, lets you traverse a mixed catalog (LibraryItem[]) with a single loop and get the correct behavior for each object according to its real type — thanks to dynamic binding. You've also learned to use is and as to reach a specific type's particular features when you truly need to, without giving up polymorphism as your main strategy.

Until now, however, Available can still be freely modified from outside the class (item.Available = true;, without going through Lend() or Return()), which could leave BiblioTech's catalog in an inconsistent state by mistake or oversight. In the next lesson, Encapsulation, you'll learn to protect your classes' internal state, allowing it to be changed only through the methods designed for that purpose.

© Copyright 2026. All rights reserved