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.
The declaration pattern — is Circle c#
s is Circle c tests AND names the value at the narrower type. c exists wherever C# says it is definitely
assigned — not only in an if's body:
class Shape { public string Name; public Shape? Next; }
class Circle : Shape { public decimal Radius; }
bool IsBig(Shape? s) => s != null && s is Circle c && c.Radius > 10m; // the right of &&
bool NoRadius(Shape s) => !(s is Circle c) || c.Radius == 0m; // the right of || when the left failed
decimal RadiusOrZero(Shape s) => s is Circle c ? c.Radius : 0m; // a ternary's true branch
string Name(Shape s) {
if (!(s is Circle c)) { return s.Name; }
else { return "circle " + c.Radius.ToString(); } // the else of a negated test
}
decimal Guarded(Shape s) {
if (s is not Circle c) return 0m; // a guard that cannot fall through…
return c.Radius; // …so `c` is assigned after it
}
decimal Chain(Shape first) {
var total = 0m;
Shape? cur = first;
while (cur is Circle c) { cur = c.Next; total += c.Radius; } // a loop body: `c` keeps its value
return total;
}After a guard clause, and in a while body, the name is a variable of its own holding the value that was tested —
moving the tested variable on afterwards (cur = c.Next;) does not change it, as in C#. The pattern forms beyond a
type test are on Patterns.
Where the test could have failed, the name is not in scope — a || s is Circle c binds nothing in the if's body,
because the body runs when a alone was true. That is C#'s rule, and the compiler answers it the same way: an
unknown name, rather than a value that silently is not a Circle.
On a type parameter — value is string s#
In a generic function or component, value is string s asks what the call bound T to. One body serves every call,
so the answer comes from the CALL SITE's type rather than the value — which matters, because in the browser an enum
member and a string are both text:
enum Shade { Light, Dark }
string KindOf<T>(T v) {
if (v is string s && s.Length > 0) { return "text " + s; }
if (v is int n) { return "number " + (n * 2).ToString(); }
if (v is Shade) { return "shade"; }
return "other";
}
string Check() => KindOf("a") + "," + KindOf(3) + "," + KindOf(Shade.Dark); // "text a,number 6,shade"The target can be a built-in type (string, int, long, bool, decimal, double, DateTime, DateOnly,
Guid, …), an enum, or an entity or class — which is tested through its hierarchy, so a Doc that IS a Contract
answers is Contract. null is still of no type. The same holds in a generic component (generic component).
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 |
x is T t inside a generic CLASS, testing the class's own T | refused — no call says which type T is there; make the method generic |
(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
- generic component — the same tests on a generic component's
T