Summary#
is asks what a value actually is, which is not always what it is declared as:
class Shape { public string Name; }
class Circle : Shape { public decimal Radius; }
string WhatIsIt() {
Shape s = new Circle { Name = "c", Radius = 2m };
if (s is Circle) { return "circle"; }
return "shape";
}The slot says Shape; the value is a Circle, and is answers by the value.
Signature#
value is Class // true when the value's runtime type is Class, or derives from it
value is not Class // the negationDescription#
It answers by the RUNTIME type#
That is the whole point — a declared type is what the compiler knows, and is is for what the compiler cannot know.
A value that really is a plain Shape answers false:
class Shape { public string Name; }
class Circle : Shape { public decimal Radius; }
string WhatIsIt() {
Shape s = new Shape { Name = "plain" };
if (s is Circle) { return "circle"; }
return "shape";
}A derived value satisfies its base#
is Shape accepts the whole subtree beneath Shape, not only an exact Shape. This is what makes it a type test
rather than a comparison of labels:
class Shape { public string Name; }
class Circle : Shape { public decimal Radius; }
string Check() {
Shape s = new Circle { Name = "c", Radius = 2m };
if (s is Shape) { return "yes"; }
return "no";
}null is of no type#
As in C#, null is not an instance of anything — so null is Circle is false, and null is not Circle is true:
class Shape { public string Name; }
class Circle : Shape { public decimal Radius; }
string Check() {
Shape? s = null;
if (s is not Circle) { return "not-a-circle"; }
return "circle";
}Narrowing a whole collection — OfType<T>()#
OfType<T>() keeps the elements that are a T and gives you them as a T:
class Shape { public string Name; }
class Circle : Shape { public decimal Radius; }
decimal TotalRadius() {
List<Shape> shapes = new List<Shape>();
shapes.Add(new Circle { Name = "a", Radius = 1m });
shapes.Add(new Shape { Name = "b" });
shapes.Add(new Circle { Name = "c", Radius = 3m });
decimal total = 0m;
foreach (var c in shapes.OfType<Circle>()) { total = total + c.Radius; }
return total; // 4 — the plain Shape is not in the set
}It does two things at once, and the second is why you would reach for it over Where: it filters to the
elements that really are a Circle, and it re-types them, so c.Radius reads. shapes.Where(s => s is Circle)
filters identically but its result is still a List<Shape> statically, so a derived field is out of reach.
It only ever narrows. Asking for the element's own base, or for a type outside its hierarchy, is refused rather than quietly widening the read or returning nothing.
Converting to the narrower type — cast or as?#
A type test answers whether; a conversion hands you the value at the narrower type. Two spellings, differing only in what happens when the value is not that type:
class Shape { public string Name; }
class Circle : Shape { public decimal Radius; }
decimal RadiusOrZero(Shape s) {
Circle? maybe = s as Circle; // null when it is not a Circle
if (maybe == null) { return 0m; }
return maybe.Radius;
}
decimal RadiusOf(Shape s) {
Circle c = (Circle)s; // FAILS when it is not a Circle
return c.Radius;
}| when it IS the type | when it is NOT | |
|---|---|---|
(Circle)s | the value, typed Circle | fails, naming both types |
s as Circle | the value, typed Circle? | null |
Use the cast when being wrong is a bug you want to hear about, and as when "not a Circle" is an ordinary case you
are about to handle. That is the same advice C# gives, for the same reason.
null converts to null under both, and neither fails. A cast of null is not a failed cast — there is nothing
there to be of the wrong type.
Widening needs no conversion at all: a Circle already goes wherever a Shape is expected
([[class-inheritance#upcast]]).
Dispatching on the type — switch#
A switch arm can be a type pattern — Circle c => — binding the value at that arm's own type:
class Shape { public string Name; }
class Circle : Shape { public decimal Radius; }
string Describe() {
Shape s = new Circle { Name = "c", Radius = 2m };
return s switch {
Circle c => "circle:" + c.Radius.ToString(), // `c` is a Circle here
Shape p => "shape:" + p.Name,
};
}The binding is required — write Circle c =>, not Circle =>. A bare name in pattern position is already an
enum member, and the binding is what tells the two apart.
Every type must be handled
A switch with no _ arm must have an arm for every type in the hierarchy. Leave one out and the app does not
compile, and the message names what is missing:
return s switch {
Circle c => "circle", // ERROR: does not handle every 'Shape' — 'Shape' has no arm
};This is stricter than C#, which only warns — and deliberately so. C#'s compiler cannot see hierarchies that other assemblies might extend, so it cannot know the set is complete. An Osy# app compiles as one unit, so the set of types is known. What that buys you is the useful half: adding a type breaks every place that has to decide about it, instead of those places silently taking a default that was never considered.
Add _ => … when the rest really are the same — that says so explicitly, and it is not second-best.
It works the same in the browser#
A type test is decided identically wherever the code runs — in a function on the server, in a component in the browser, and across a suspension that starts on one side and resumes on the other. There is no rule to learn about where you may write it.
Entities test their type too#
is works on an entity hierarchy as well, and reads the same. The two are decided by
different means — an entity is a row and carries its type in a column, which is what lets an entity's test run inside
a database query — but nothing about writing one differs.
Errors#
| you wrote | what you get |
|---|---|
x is Unrelated, where the two share no hierarchy | refused — no value can be both, so the answer would be a constant you did not write |
x is Solo, where Solo has no base and no subtypes | refused — a type test is only meaningful inside a hierarchy |
(Circle)s where s is not a Circle | fails at run time, naming both types and pointing at is / as |
(Circle)s where the two share no hierarchy | refused at compile time — no value can be both |
(Contract)order between two ENTITY types | refused — a row already carries its type; narrow the READ with OfType |
xs.OfType<Circle>() where Circle does not derive from the element type | refused — no element of the set could be one |
xs.OfType<Shape>() where Shape is the element's BASE | refused — that would WIDEN the read, not narrow it |
See also#
- Class inheritance — declaring the hierarchy a type test asks about
- entity Sub : Base — the same question asked of rows
- Classes — what a class is