C# vs. TypeScript type-narrowing and coercion
Published by marco on
I was working with a colleague to get the properties that have a particular attribute. The original formulation returned the properties then got the attributes again, plucking the first one off of the list and asserting that it exists to convince the compiler that everything’s OK. We know it exists because otherwise we wouldn’t have returned the property—but the computer doesn’t know that.
Ok, it works but it’s not efficient or elegant. Is there some way to build this so we allocate minimally and don’t have to use the null-forgiving (“dammit”) operator?
I proposed the following formulation. The null-forgiving operator bugs me a bit because I feel like TypeScript would have determined that attribute
could no longer be null
. C#/Roslyn doesn’t do that.
private static IEnumerable<(PropertyInfo PropertyInfo, TAttribute Attribute)> GetPropertiesAndAttributes<TAttribute>(Type type)
{
return
from prop in type.GetProperties()
let attribute = prop.GetCustomAttributes(typeof(TAttribute), false).FirstOrDefault() as TAttribute
where attribute != null
select (prop, attribute!);
}
My collaborator prefers the non-query syntax for Linq, so he rewrote it as follows.
private static IEnumerable<(PropertyInfo PropertyInfo, TAttribute Attribute)> GetPropertiesAndAttributes<TAttribute>(Type type)
{
return packetType
.GetProperties()
.Where(prop => prop.GetCustomAttributes(typeof(TAttribute), false).Length != 0)
.Select(propInfo => (propInfo, propInfo.GetCustomAttribute<TAttribute>()!));
}
I really don’t like that it calls both GetCustomAttributes()
and GetCustomAttribute()
, so I looked into how to do emulate let
with chained-method syntax. I found Code equivalent to the ‘let’ keyword in chained LINQ extension method calls (StackOverflow) and rewrote the code as follows.
private static IEnumerable<(PropertyInfo PropertyInfo, TAttribute Attribute)> GetPropertiesAndAttributes<TAttribute>(Type type)
{
return packetType
.GetProperties()
.Select(propInfo => (propInfo, attribute: propInfo.GetCustomAttributes(typeof(TAttribute), false).FirstOrDefault() as TAttribute))
.Where(t => t.attribute != null)
.Select(t => (t.propInfo, t.attribute!));
}
I still can’t get rid of the second Select()
because the type of the first Select()
is
(PropertyInfo PropertyInfo, TAttribute? Attribute)
rather than
(PropertyInfo PropertyInfo, TAttribute Attribute)
As in the other formulations, we still need the null-forgiving operator to coerce the type. In the final formulation, it’s much clearer that this is only required for the compiler because the check that attribute
is not null
is made on the immediately preceding line.
I was curious about TypeScript, though, C# only supports narrowing conversions for inbuilt primitives. Typescript is fancier. I used TypeScript Playground for the examples below.
Here’s where we stand with C#, rewritten in TypeScript:
At this point, TypeScript is making the same complaint as the C# compiler would.
However, if you remove null
values from the result, TypeScript recognizes that and automatically narrows the type to number[]
from (number | null)[]
.
I just realized that, while this example is interesting, I hadn’t replicated the example from C# because I wasn’t using tuples.
So, let’s try again and see how far TypeScript gets.
We already have a problem because TypeScript represents tuples with square brackets, which means that this could be an array of two-element tuples or a two-dimensional array. TypeScript defaults to the latter.
We fix that with an explicit type.
If I assign null
to b
in one element, we have a problem, as expected.
We fix that by adjusting the type of the local variable to be [a: number, b: number | null]
.
Now, we have the type-conversion error on the result.
We fix that the same way as we do in C#, with a Select
, which is called map
in TypeScript/JavaScript.
Since TypeScript doesn’t allow you to directly address tuple elements like C# does, we have to “destructure” the elements with const [a, b] = x)
. So, we have the same thing as in C#, where we allocate “new” tuples for the result. What we don’t have is a !
at the end because TypeScript recognizes the type-narrowing. Clever compiler.
Of course, TypeScript can do this because it’s just transpiling to JavaScript, which plays very fast and loose with types anyway. In C#, the compiler has to make decisions about the shape of the memory it uses, so a Nullable<int>
is going to have a different representation than an int
. If you want to go from the former to the latter, then you have to define a conversion operator, either an explicit
one or an implicit
one. Or, as we did, you have to create a new tuple, which entails an allocation. Unfortunate, but unavoidable.
In JavaScript, that’s not the case, at least until it’s run through a JIT, in which case other heuristics would have indicated how to most efficiently shape the storage for that particular instance.
If that sentence doesn’t make sense and you have a follow-up question, then me-from-ten-years-ago is here to offer more in the article Optimizing compilation and execution for dynamic languages, which summarizes a much-longer document written about the WebKit JavaScript engine.
My colleague, who is always game to play with language features, had the good idea to try it with filter()
.
This confirmed for us that the TypeScript checker doesn’t actually know anything about the effects of the methods filter
and map
. It’s just that the narrowing happens in the closure passed to the map
version, so it can determine that there is no way that b
will ever be null
when the closure exits. Since it doesn’t know the semantics of filter
, it doesn’t know that it actually does that too.