Last Updated: May 17, 2026
When a variable is typed as a base class, the compiler only sees what the base class promises. At runtime, the object inside could be any subclass, and sometimes you need to know which one before doing something specific. C# offers two operators for that job: is for checking and as for safely casting. This lesson covers both, the modern pattern-matching form, the older direct cast, and when to skip type checks entirely in favor of polymorphism.
Inheritance lets a base-typed variable hold any subclass instance. That's the whole point: a method that takes Product doesn't care if the caller passes a Book or Electronics. But sometimes the caller of that method needs to do something specific to one subtype.
The variable p is typed Product, even though the actual object on the heap is a Book. The compiler only lets you touch what Product exposes. Author is invisible through this reference, even though the object has one. To use the more specific type, you have to convince the compiler that the object really is a Book.
There are three ways to do that in C#: a direct cast, the is operator, and the as operator. Each fits a different situation.
A direct cast forces the compiler to treat a reference as a target type. The syntax is the same as numeric conversion:
This works because the object really is a Book. The cast doesn't change the object; it just changes the type of the reference you use to access it. The bytes on the heap are the same.
The danger shows up when the object isn't what you claim:
The compiler accepts the cast because both types share Product as a base, so the conversion is theoretically legal. The runtime checks the actual type and throws. Direct casts are a way of telling the compiler "trust me, I know what this is." If you're wrong, the program crashes.
Use a direct cast only when you have already proven the type some other way (you just checked it, or the code that produced the value guarantees it). For anything uncertain, is and as are safer.
is Operator: Type CheckingThe is operator answers a yes-or-no question: is this reference compatible with that type? It returns a bool and never throws. Compatibility means the runtime type is the target type, or derives from it, or implements it (when the target is an interface).
is checks the runtime type, not the declared type. Even though p is declared as Product, the object is really a Book, so p is Book is true. The check climbs the inheritance chain: a Book is a Product is an object, so all three checks against ancestors succeed. Asking about an unrelated subclass (Electronics) returns false.
A common use is gating code that needs the specific type:
The pattern works, but it has a small annoyance: you do the type check once, and then the cast does the same check again under the hood. Modern C# has a cleaner form that combines both steps.
is with Pattern Matching: The Modern IdiomSince C# 7, the is operator can declare a variable in the same expression. If the check succeeds, the variable is bound to the cast value and is in scope for the rest of the block.
The expression p is Book book does two things at once: it checks whether p is a Book, and if so, declares a new variable book of type Book holding the cast reference. Inside the if block, book is available and typed Book, so .Author is accessible without a separate cast. This is the form most modern C# code uses.
A short refresher on scoping. The pattern variable is in scope wherever the compiler can prove the check succeeded. That's the if body, but also the rest of an expression after &&:
The variable book is usable in the second half of the && because by the time that expression runs, the check has already passed. If the first half had been false, short-circuit evaluation would skip the second half and book would never be touched.
The pattern variable is not in scope where the check might have failed:
Outside the if, the compiler can't guarantee book was assigned, so reading it is a compile error.
as Operator: Safe Cast or NullThe as operator tries a cast and returns null if the conversion isn't valid. It never throws an exception. It works only with reference types and nullable value types, because the result has to be able to hold null.
The variable b1 holds the original object reference, viewed as a Book. The variable b2 is null, because p2 isn't a Book. No exception, no crash, just a null reference you have to check for.
The classic use is when you expect a particular type but want to handle the off case gracefully:
as won't compile against types that can't hold null. Trying int n = obj as int is a compile error (CS0077). Use int? instead, or use is with pattern matching, which works for value types just fine.
Cost: Both is and as perform a single type check at the IL level (the isinst instruction). The pattern (p is Book b) and (p as Book) != null are essentially the same operation under the hood. Don't write both back-to-back, you're paying for the check twice.
is vs as vs Direct CastThree operations, three behaviors. The table below summarizes when each fits.
| Operator | Returns | Throws? | Works on value types? | Use when |
|---|---|---|---|---|
(T)x direct cast | T | Yes (InvalidCastException) | Yes | You already know the type |
x is T | bool | No | Yes | You need a boolean check |
x is T t | bool (+ binds t) | No | Yes | You need both check and cast in one step |
x as T | T or null | No | Reference / Nullable<T> only | You want a null-returning cast for explicit handling |
The modern recommendation in most C# code is straightforward: use x is T t when you want to check and use the value, use x as T when you specifically want to fall back on null, and reserve direct casts for cases where you've already proven the type. Plain x is T (no pattern variable) is fine when you only need the boolean answer, like in a where clause or filter.
A side-by-side comparison of the three styles doing the same job:
All three produce the same result. Style 3 is the shortest and the one most C# style guides recommend, because it combines the check and the variable binding into a single, intention-revealing expression.
The diagram below shows what is does at runtime when you ask whether a Product reference points to a Book. The same flow applies to as, except the failure path returns null instead of false.
The runtime first guards against null (a null reference isn't an instance of anything, so null is Book returns false). If the reference is non-null, it reads the type token from the object header and walks the inheritance chain looking for Book. A match returns true; no match returns false. The whole operation is constant time for a given depth of inheritance.
not, and PropertyC# pattern matching grew beyond simple type checks. A few patterns come up often in code that handles base-typed references.
A constant pattern matches against a literal value. The most common case is the null check:
p is null is the modern C# way to test for null. It's preferred over p == null because user-defined equality operators can override == to do strange things, but is null always means "the reference is null, full stop." When Equals and == are overridden, is null keeps its meaning while == might not.
A `not` pattern negates another pattern:
Both read more naturally than !(p is null) or !(p is Book). The negation lives inside the pattern, which keeps the parentheses simple.
A property pattern matches against the values of an object's properties at the same time as it checks the type:
The pattern matches when p is a Book and its Author property equals "Robert Martin". The pattern variable book still binds to the typed reference. Property patterns are useful when filtering or routing on more than just type.
These deeper patterns belong to a full pattern-matching lesson later in the course. The point here is that is isn't just about types: it's the gateway to a richer set of checks that all share the same shape.
A typical use of is and as is processing a collection of base-typed objects and doing something specific for each subtype. The cart in an e-commerce system is the classic example: it holds Product references, but books and electronics may need different handling.
Here's a cart that prints a summary, showing the author for books and the warranty for electronics:
Each iteration tests the runtime type with a pattern variable. When item is Book b succeeds, b is in scope inside that branch with full access to Author. The else if does the same for Electronics. This shape works, but it's also a hint that the design might be improvable, which we'll return to at the end of the lesson.
A cleaner shape for a single subtype is LINQ's OfType<T>. It returns only the elements that match the target type, already cast:
OfType<Book>() does the same type check as is Book, but built into a LINQ operator that filters and casts in one step. Internally it uses the same isinst IL instruction. Use it when you want a strongly-typed sub-collection of one subtype, and use is with a pattern variable when you're branching on multiple subtypes inside one loop.
Cost: OfType<T> is lazy: it doesn't allocate a new list until you enumerate it. Calling .ToList() at the end materializes the filtered sequence. Skip the .ToList() if you only iterate once.
Branching on runtime type isn't wrong, but in many cases it's a sign that the design isn't using inheritance to its full potential. Whenever you find yourself writing a chain of if (item is Book) ... else if (item is Electronics) ..., ask whether each branch could be replaced by a virtual method on the base class.
Here is the cart example again, written with type checks:
Now the same thing using a virtual method, with each subclass overriding it:
The loop went from a branching cascade to a single line. Adding a new product type, say Clothing, doesn't require changing the loop; you just write a Clothing class with its own Describe() override and the existing code picks it up. The branching version, by contrast, has to be updated every time a new subtype is added, and any place else in the codebase that does the same kind of dispatch has to be updated too.
This is the heart of polymorphism: the base class declares the contract (virtual string Describe()), each subclass fills in the details, and callers don't care which subtype they have. Virtual methods get a chapter of their own (Method Overriding), and the contrast between compile-time dispatch and runtime dispatch is the entire topic of the Polymorphism section.
is and as are still legitimate tools. Use them when:
switch expression that genuinely belongs to one place (a single dispatch table that doesn't get duplicated elsewhere).The smell isn't the operator, it's the repetition. One isolated type check is fine. A type-check ladder that appears in three different files, each updated separately when a new subtype is added, is the structure virtual methods were designed to replace.
There are cases where a type check is the right tool, not a smell. The most common is when the behavior in question isn't really about the type, it's about an integration boundary.
A logging system might receive arbitrary object values and want to format known shapes specially:
The logger doesn't own string, int, or DateTime. It can't add a virtual Log method to them. Type-matching at the boundary is the only option, and a switch expression on patterns is the modern way to write it. The same applies to deserialization, JSON converters, and any code that sits at the edge of a system.
The rule of thumb: if you own the types and they form a coherent hierarchy, prefer virtual methods. If you don't own the types or they're heterogeneous, pattern-match on type. Both tools belong in your toolbox; the trick is recognizing which situation you're in.
A complete program that uses everything from this lesson: a cart of products, a filter using OfType, a dispatch using is with pattern variables, and an as cast for an optional check.
The program uses OfType<Book>() to filter the cart down to just the books for the first summary, then loops the full cart with is-pattern dispatch for the receipt, and finally uses as on the first item to demonstrate the null-fallback shape. Each operator earned its place: OfType for a typed sub-sequence, is with a pattern variable for branching, as when the result might legitimately be null.
Type checking with is and as answers "what kind of object is this?". The next question is "when do we consider two objects equal?", and that's what Equals() and GetHashCode() handle, with rules that trip up almost everyone.
Product p = new Book(...)) only exposes the base type's members. To use a subtype's members, you have to check or cast.(T)x throw InvalidCastException if the runtime type doesn't match. Use them only when you've already proven the type.is T returns a bool without throwing. is T t adds a pattern variable that's in scope wherever the check is known to have succeeded.as T returns the value as T if compatible, or null otherwise. It works on reference types and Nullable<T>, not on plain value types.is null over == null because is ignores user-defined equality operators and always means "the reference is null."OfType<T> filters a heterogeneous collection to one subtype. Use it when you want a typed sub-sequence; use is with a pattern variable when branching on multiple subtypes in one pass.not and property patterns (is not null, is Book { Author: "X" }) extend the same operator to richer checks; the full pattern-matching toolkit gets a dedicated lesson later.The next lesson, Equals() & GetHashCode(), moves from "what kind of object is this?" to "when do we consider two objects equal?", including the contract every overridden Equals must follow and the rules around hash codes that catch almost every developer off guard.