TypeScript: [Proposal] Type assertion statement (type cast) at block-scope level
This is a proposal in order to simplify the way we have to deal with type guards in TypeScript in order to enforce the type inference.
The use case is the following. Let us assume we have dozens (and dozens) of interfaces as the following:
Code
interface AARect {
x: number; // top left corner
y: number; // top left corner
width: number;
height: number;
}
interface AABox {
x: number; // center
y: number; // center
halfDimX: number;
halfDimY: number;
}
interface Circle {
x: number; // center
y: number; // center
radius: number;
}
// And much more...
And we have a union type like this one:
type Geometry = AARect | AABox | Circle | // ... And much more
It is quite easy to discriminate a type from another with hasOwnProperty or the in keyword:
function processGeometry(obj: Geometry): void {
if ("width" in obj) {
let width = (obj as AARect).width;
// ...
}
if ("halfDimX" in obj) {
let halfDimX = (obj as AABox).halfDimX;
// ...
}
else if ("radius" in obj) {
let radius = (obj as Circle).radius;
// ...
}
// And much more...
}
But, as we can see, this is quite burdensome when we need to manipulate obj inside each if block, since we need to type cast each time we use obj.
A first way to mitigate this issue would be to create an helper variable like this:
if ("width" in obj) {
let helpObj = obj as AARect;
let width = helpObj.width;
// ...
}
But this is not really satisfying since it creates an artefact we will find in the emitted JavaScript file, which is here just for the sake of the type inference.
So another solution could be to use user-defined type guard functions:
function isAARect(obj: Geometry): obj is AARect {
return "width" in obj;
}
function isAABox(obj: Geometry): obj is AABox {
return "halfDimX" in obj;
}
function isCircle(obj: Geometry): obj is Circle {
return "radius" in obj;
}
// And much more...
function processGeometry(obj: Geometry): void {
if (isAARect(obj)) {
let width = obj.width;
// ...
}
if (isAABox(obj)) {
let halfDimX = obj.halfDimX;
// ...
}
else if (isCircle(obj)) {
let radius = obj.radius;
// ...
}
// And much more...
}
But again, I find this solution not really satisfying since it still creates persistent helpers functions just for the sake of the type inference and can be overkill for situations when we do not often need to perform type guards.
So, my proposal is to introduce a new syntax in order to force the type of an identifier at a block-scope level.
function processGeometry(obj: Geometry): void {
if ("width" in obj) {
assume obj is AARect;
let width = obj.width;
// ...
}
if ("halfDimX" in obj) {
assume obj is AABox;
let halfDimX = obj.halfDimX;
// ...
}
else if ("radius" in obj) {
assume obj is Circle;
let radius = obj.radius;
// ...
}
// And much more...
}
Above, the syntax assume <identifier> is <type> gives the information to the type inference that inside the block, following this annotation, <identifier> has to be considered as <type>. No need to type cast any more. Such a way has the advantage over the previous techniques not to generate any code in the emitted JavaScript. And in my opinion, it is less tedious than creating dedicated helper functions.
This syntax can be simplified or changed. For instance we could just have :
<identifier> is <obj>
without a new keyword assume, but I am unsure this would be compliant with the current grammar and design goals of the TypeScript team.
Nevertheless, whatever any welcomed optimization, I think the general idea is relevant for making TypeScript clearer, less verbose in the source code and in the final JavaScript, and less tedious to write when we have to deal with union types.
About this issue
- Original URL
- State: open
- Created 8 years ago
- Reactions: 130
- Comments: 84 (33 by maintainers)
Technically we could just consider
inas a form of type guards.But I can still imagine something that says “assume the type of this entity is so-and-so for this block”.
how about adding the typeguard after the
ifcondition itself by re-using the existing typeguard syntax:I found an absurd workaround for this issue. For some reason, using a
@ts-ignorebefore adeclarestill declares the variable type, but suppresses the warning thatModifiers cannot appear here.In fact, this could be a possible syntax if this suggestion were to be implemented, since it doesn’t create any new keywords.
EDIT:
This only works in a new scope, for example inside a block. Usually, since this is useful to augment type narrowing, this is not an issue. However, casting variables such as function arguments using
letproduces a duplicate declaration error, and usingvarsimply fails silently.A possible solution for the original question with #32695 could look like this (I think):
The
assumeTypefunction is a no-op, so any decent minifier like terser or babel-minify should be able to remove those function calls. That should result in zero run-time overhead.I have to admit it’s not as pretty as the proposed
assume x is Tsyntax. Still, I believeassertsis the more versatile solution and can be applied to other problems, so I’m happy with it. 🙂@pm-nsimic I like that. It’d also make most existing type narrowing situations just special case sugar:
The fact that casting from one type to another still requires reassigning a variable or executing an actual function means that this isn’t really solved for me.
Developers should have the power to control this kind of thing easily:
The fact is, that entire line can just be removed with the TypeScript compilation process. It doesn’t seem to add so much complexity that its too hard to implement? Its basically just an assertion without needing to call a function (add unnecesary computaitonal complexity- something we always want to strive for no matter how irrelevant it seems because in certain scenarios it can add up).
I agree that unions should be the thing to solve most of this stuff- but the fact is that often times, with large and complex unions this can be a huge challenge and sometimes doesn’t work out how you’d expect because of optional properties or a union that’s based off of something more nuanced than an exact property value.
All in all, I’d like TypeScript to work for me- not against me- when I know exactly what I’m doing.
assume foo as *is a purposeful syntax- if its misused, like theanytype and lots of other things in TypeScript can be misused, then that’s on the developer.I know that the current way we are expected to get this functionality (without needing an assert function or re-assign) is to do this:
I don’t think I need to elaborate on why this is terrible… But this is even worse as far as developers making mistakes goes- because now if we don’t remember to type-cast foo every time its actually not the intended type.
I think that this issue can be closed because Typescript already narrows the type correctly (at least since version 3.3, which is the last one that we can test in the playground).
Playground
Redirecting here from #8655, I’m not fond of the idea of requiring an
ifblock. I understand that there’s architectural constraints that requires a syntactic signal for whether to perform narrowing, but requiring anifdoes not handle the case of assertion functions.Closure Library and Compiler have had very good success with debug-only assertions, such as
The compiler understands this as a type assertion and narrows the type of
athrough the rest of the control flow. TS can handle this right now withbut there’s no good way for an optimizer to remove this later. If we had some way to annotate a function call as a type-narrowing assertion that could show up in the AST to resolve the performance issues, then the function call could be retained and possibly removed in post-processing. Note that I’m not asking for TypeScript to get into the optimization business, but the current design is a particular impediment to working well with third-party optimizers, which seems somewhat in line with goal 3 (“Impose no runtime overhead on emitted programs” - but the current solution does) and in line with the counterpoint to non-goal 2 (“Instead, emit idiomatic JavaScript code that plays well with the performance characteristics of runtime platforms”).
Unfortunately, I’m not thinking of any particularly good syntax to annotate this. It does remind me a bit of the expression-level syntax for non-null type assertions, but I don’t see a good way to extend that at all.
declare typeof foo: Foo? Not introducing a new keyword and literally (re)declaring the type offooasFoo. And as TypeScript users we all knowdeclarewon’t affect emitted JavaScript.@yahiko00 User defined type guard functions actually are an important part of the emitted code. They may determine control flow at runtime. Also this is not type inference, it is type assertion (or type assumption 😛 ). If you want shorthand type guards you can write
then you can write the following
@lostpebble your last workaround doesn’t really address this. The whole purpose of this issue is to make an existing variable be inferred as a different type, not create a new variable nor make function calls that impact runtime. Otherwise the simplest is:
But again, the whole purpose is to avoid that runtime change entirely.
Okay, so what about an unreasonable optimizer or none at all?
So you are saying the bottom line for type casting in TypeScript is to have useless functions or re-assignments in your code. TypeScript to me has always been about augmenting JavaScript with types for more safety- but here its forcing us to actually add unnecessary JavaScript to get the types we want.
The fact is its not “done for free”- it compiles down to an actual function call for something which is completely useless for your end JavaScript code. Whether a developer later uses a great optimizer down the line to get rid of that function is a different topic.
Basically, to achieve type-casting now with how you say we should do it, TypeScript expects our code to end up looking like this:
I don’t really get why we can’t still aim to achieve this instead of being dismissive and conceding that it adds too much complexity for what it achieves. Internally TypeScript appears to have the ability to cast in a block with that assert function- why not just allow the same but with a special syntax.
(I’m not saying the syntax I mentioned has to be the final one either, just that it looks pretty nice and doesn’t clash with current typescript keywords)
I think it is still worth pointing out that there are still some cases of type inference that AFAIK have not been solved yet in Typescript. Playground Example:
So assertions could really help, either the form of the
assume x is _, or inline type guards based onifstatements:@jdom See #32695. 😃
@aluanhaddad
if("width" in obj)could narrowobjtoAARectif the declared type ofobjis a union likeAARect | AABox | Circle.Why hasn’t this been included for so many years? Reanimated in React-Native really could use it - we have to face multiple architectures, platforms and it would be nice if after checking what platform we are on we could inline type guard some parameters to be platform specific. Since performance is key for us even a couple type guard calls can be too expensive and multiple type assertions are just cluttering the code.
@shicks Thanks! I have seen that workaround, and it mostly works, but does technically break
Function.prototype.lengthfor the function. I think in the spirit of this feature request, the desire is for Typescript code to completely compile away. In general, having to write code differently not b/c it is bad practice, but b/c the type engine cannot understand it, is a very frustrating dev experience.I think having a block-scoped assertion mechanism would really help to act as a stopgap for this and other edge cases in the typing system. As the typing system gets more and more sophisticated, it can be used less and less.
@MattiasBuelens
The way I got it working was slightly different
The real world example was to assert the types of redux actions my reducer was processing:
It works but everyone has to roll their own implementation in the code for what looks like a pretty common use case.
@saschanaz I personally feel
assumehits that high bar, though, about as much askeyofdid.But of course, I’m not the arbiter of this, so I’ll dive back into the sidelines now.
So what you are really proposing is a type assertion statement that is otherwise exactly like the existing type assertion expression. For example:
As with other statement/expression equivalences, this proposed new form of type assertion is used for its side-effects rather than its result, although obviously these are compile-time side-effects, since type assertions are elided at runtime. And as you mention you want the side-effects limited to the block in which the assertion appears.
If you write a no-op type guard, you shouldn’t see any performance regression. Nearly every VM will do JIT optimization to inline the empty function call into the calling functions’ bytecode. The only exception I’m aware of is iOS native, which doesn’t allow it, but in that case you’re already bundling, and every bundler can (and will) also inline empty functions in production builds. Such an empty type guard isn’t particularly “safe” (since it’s not actually doing any checking at runtime), but depending on how you type it, it’s no less safe than an ordinary
as Ttype assertion that you’d write without it.See https://jsbench.me/00ls2rex1g/1
What about these alternatives?
We could also only allow narrowing in
if/elseblock:@saschanaz
I’m not sure it should:
Yeah, I don’t really like that syntax too well myself. Compare that to this:
Maybe duplicated type redeclarations should also throw:
BTW, my example looks weird to myself because
declarehas never been allowed in a block.@lemoinem That’s basically my proposal, just using
declare(which doesn’t make as much sense - you’re not declaring anything) instead of a contextualassume.I like the original proposal’s syntax, because if you need to narrow the scope of the type assertion, you can just add a block and make the assertion in there.
Regarding the alteration of the type of already declared variables in the current scope, with let and cost, the compiler can just throw an error complaining about multiple declarations, which makes total sense.
In my view, this type of type assertion should be nothing more than a type declaration, with the condition that the variable already exists in a higher scope.
Also, I don’t agree with the use of declare keyword here. It should be something new or something unrelated, so their meanings and use don’t conflict with each other.
I don’t think it would be wise for there to be a surrounding block since that would alter the meaning of
letandconstdeclarations.It would be safer to scope the effect of the assertion to the enclosing block.
a && b; sounds good at first but it could have side effects so it’s not inert like, say, a pragma.
Consider:
In my mind,
<identifier>included both simple variable identifiers and properties. But after checking the grammar specifications, I should have been more precise. 😉 So yes, both identifiers and properties should be allowed in type assertion statements.BTW I see you’re only proposing this to work on simple identifiers, whereas type assertion expressions work on arbitrary expressions, like
foo.bar as string. Type guards also work with properties, likeif (isString(foo.bar)) {foo.bar.length}. It might be useful to consider at least some expressions as valid in type assertion statements, for exampleassert foo.bar as string;For reference, the spec on type assertion expressions (section 4.16) is here.
The rules and use-cases described there for type assertions would presumably apply to this proposal in exactly the same way.
This looks like the same concept as #9946, but with different syntax.
@tjzel I agree 100% that something like this in the language would be amazing- our comments are almost 1 to 1 (see earlier in the thread).
I’ve found that this is probably the simplest way to do this as of now:
The syntax isn’t so bad- and feels natural enough.
But yea, having to do this could be dangerous overall and remove some of the type safety that TypeScript gives to us. But there definitely are places where this comes in handy (not every project is greenfield).
I love this idea. This thread is really long but what’re the current blockers?
Just wanted to drop another use case for this that I documented on StackOverflow.
I came up with a similar solution as @MattiasBuelens’s
assumeType<T>assertion. But it still would be nice not to have to rely on tree shaking/dead-code elimination to achieve this without runtime consequences. Especially since that elimination could be prevented by the presumptive possibility of side effects in some cases.@EnderShadow8 this is interesting 🙂
I did some tests to see if I could break it, but it seems to work - my main worry was the redeclared types would somehow leak to the
.d.tsfile, but that does not appear to be the case, and IDE support looks sound as well.I couldn’t find any gotchas - is this really as simple as just lifting the restriction and allowing what already works? 🤷♂️
It should probably still have a dedicated syntax though, since
letseems to imply we’re actually declaring a new variable with the same name, shadowing a variable in the parent block scope, which isn’t really what’s happening. Although, in practical terms (in terms of typing) I suppose the effects of doing that would be exactly the same.If nothing else, this makes for a pretty nifty workaround. 😃👍
EDIT: the only caveat I can find is the fact that the new symbol is just that: a new symbol, shadowing the symbol on the parent block scope - which means, your inner
objis an entirely different symbol, disconnected fromobjin the parent scope, as you can see if you use “find references” … as you can see in the example here, there’s no relationship with the declaration on line 18:So the risk of doing this, is it will break if you rename or remove the parent symbol - and emit code that fails.
@yahiko00 It won’t create it, but it will have to emit the call because the call could have side effects (like throwing or object mutation).
@rzvc I personally would prefer both of these forms:
assume obj is Foo;- This statement within a block scope is equivalent to the following, whereassumeis a unique global name not exposed to the user:obj is Foo- This expression is exactly equivalent tocast<Foo>(obj), wherecastis defined asfunction cast<T>(obj: T): T { return obj }The syntax can be something like
All the current suggestions seem to propose a means of altering the type within the current scope, e.g.:
How about making the scope of the type-cast explicitly block-scoped?Nope! see comments below.If I had to pick from the options listed by @yahiko00, my favorite is (1):
👍
That was actually the thing that gave me the same idea - in fact, I was intuitively just trying it when I ran into this problem, hoping it would just work.
I like the fact that this resembles ambient declarations, because it is ambient (doesn’t emit any code) and it is a declaration - even if it isn’t quite an “ambient declaration” in the Typescript sense, it seems pretty intuitive.
Unlikely to happen, I think - since ambient declarations are always top-level declarations?
Regarding (3) I find the introduction of a keyword
assumeis sort of inconsistent with other type-casts, which don’t require a keyword.Even if (5) is visually ambiguous with other type-casts, I like that option as well, as it’s the closest relative to other type-casts in Typescript. It’s actually not inconsistent with JS itself, where, for example, stand-alone expressions like
a && b;, even if they’re completely inert, are syntactically valid.