Repository navigation
Allow type narrowing to be specified as always-on or always-offΒ #57725
Description
π Search Terms
type narrowing of functions, asserts on getters
β Viability Checklist
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- This isn't a request to add a new utility type: https://github.com/microsoft/TypeScript/wiki/No-New-Utility-Types
- This feature would agree with the rest of our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals
β Suggestion
Right now, properties of objects can always be narrowed, whereas functions can never be narrowed. As a developer, I would like to specify when functions can be narrowed, and when object properties can't be narrowed.
π Motivating Example
Consider this example:
export const model = {
get value() {
return Math.random() > 0.5 ? 'Hello' : undefined
},
getValue() {
return model.value
}
}
if (model.value) {
console.log(model.value.toLowerCase())
}
if (model.getValue()) {
console.log(model.getValue().toLowerCase())
}In the TypeScript playground, you can see that even though these code paths are identical in terms of output and function calls, one is narrowed and one is not. TypeScript always assumes that properties are always stable in their values between calls, and assumes that functions are always in-stable in their calls.
You can see this in the TypeScript error which does not error for the property, even though it should.
π» Use Cases
- What do you want to use this for?
I'm more concerned with the stable function case than the instable property object case. I'm building a Knockout-like observable library that lays on top of Vue. It works fine / perfectly in the Vue ecosystem, however it's TypeScript that doesn't behave here. foo.value is always type-narrowed, whereas foo() is never type-narrowed, even though I can guarantee its stability between calls. Because this behavior in TypeScript is automatic (as far as I know?), there's no way to specify which function calls are stable (and can be narrowed) and which ones are not.
- What shortcomings exist with current approaches?
You can type narrow by assigning the returned value of the function to another variable. However, in Vue, this approach is limited when binding to templates, where v-if will not type-narrow a functional getter.
- What workarounds are you using in the meantime?
A very clumsy workaround is using a Vue computed(), which then also type-narrows.
Activity
fatcerberus commentedon Mar 11, 2024
This seems like a use case for
pureannotations. The compiler would need to know the function has no side effects and its output depends only on its input for this to work properly.matthew-dean commentedon Mar 11, 2024
Bruce Pascoe (@fatcerberus) I agree. But, likewise, there is a need for
impureannotations from unstable properties, or Proxies etc.eneajaho commentedon Jul 26, 2024
This would solve Angular signals type narrowing too angular/angular#49161
eneajaho commentedon Dec 14, 2024
Having this would make Angular with Signals way simpler to use, and the Angular team won't have to "hack the template language" (angular/angular#55456) in a way to make it work on Angular templates only, as the issue will still remain in normal typescript code.
Hoping we can have some kind of solution for this one π
Also, as I've commented above, there's a lot of discussion already regarding this topic in this issue angular/angular#49161
tolutronics commentedon Dec 17, 2024
This will make reactive programming in Angular smoother. I support.
eneajaho commentedon Dec 17, 2024
Related issues/discussions:
voidΒ returning assertionΒ functionsΒ #40562Juamedrod commentedon Dec 17, 2024
Someone had to say it. This help angular signals assertion in templates.
arnoud-dv commentedon Dec 18, 2024
Yes please! β€οΈ It would allow me to remove this paragraph from the TanStack Query Angular documentation and greatly improve developer experience. And remove clunky workarounds that only partly work.
https://tanstack.com/query/latest/docs/framework/angular/typescript#type-narrowing
arnoud-dv commentedon Dec 18, 2024
If both getters and methods returning a boolean can narrow (using a type predicate) it would make sense to me that other methods calls should be able to do this as well.
jsgoupil commentedon Dec 19, 2024
I love how Matthew Dean (@matthew-dean) introduces the concept of a method being considered pure by demonstrating why that getters are not π .
The use of impure getters has always been a contentious topic. From their inception, the consensus was clear: "Keep them simple." Over time, this evolved into a convention where getters are expected to remain pure.
Programming with nullability in mind marks a significant paradigm shift, especially when comparing modern code to practices from a decade ago. Today, ensuring code is safeguarded against null reference errors has become a standard in TypeScript. As a result, the convention that getters are pure while functions are not has been widely adopted and integrated into development practices.
If Angular is confident that it knows that its
signal()call is pure, leveraging TypeScript functionality to return a plain getter would have been a more elegant approach. This would eliminate the need for the parenthesessyntax(), allowing developers to write intuitive expressions like:if (mySignal) { console.log(mySignal.property); }without triggering compiler complaints.So why are we still using
()with signals and functions? The answer lies in their impurity. Shifting the responsibility to developers to mark these as "pure" risks introducing bugs and undermining the compiler's safeguards.Angular already incorporates purity concepts with pipes, supported by documentation to emphasize their importance. However, do developers consistently grasp and implement these concepts correctly? If Angular aims to introduce "pure signals," the annotation could be handled at the framework level, whether in Angular, Vue, or similar ecosystems, leveraging their existing pre-compilation processes for HTML templates.
I am going to be unpopular here, in fact, one could argue for an entirely different approach: marking getters as unstable while reserving stability exclusively for real fields/variables. This would prevent the proliferation of additional annotations and maintain the integrity of the type system. Otherwise, allowing developers to override purity checks feels analogous to loosening type constraints with
any, a way of telling the compiler, "Trust me, I know what Iβm doing." But do we really?TypeScript, as a superset of JavaScript, it has been great in safeguarding us from making common mistakes. However, placing even more power in the hands of developers introduces inherent risks. Shouldn't be more though-provoking and propose an
@Idempotentattribute as well?JeanMeche commentedon Dec 28, 2024
One could argue that this is driven not by purity, but by API design. Having invocable signals, makes them akin to any other function.
Any function that takes in a signal as argument, actually takes in a
() => T. No signs of signals, which streamlines composition.ryansolid commentedon Jan 6, 2025
I'm very happy that someone wrote an issue about this. I never knew how to phrase what I was looking for properly or I would have created an issue 6 years ago. Solid community is very motivated to have this addressed. Signals in general have been pushed towards
.valueie a getter, or using classes in a lot of libraries because of the types. This is really unfortunate because it gets in the way of composition. Now you need to create more reactive primitives or manually make like a value getter helper to make reactivity transitive if you want an API that only accepts Signals via shape.Of course you can make a
computedor other reactive node but that comes with a consequence. It makes it part of the graph, impacting re-execution and it impacts memory usage. These things add up.And regardless of our chosen Signal syntax wrapping a reactive access in a function will transfer reactivity:
This means every Signals library will get back here almost immediately regardless of their choice. Which means forcing a getter is not really a solution.
I've made it no secret I've struggled making TS work for SolidJS over the years and have in many ways needed to limit my expectations of what is possible due to it. But solving this would be by far the most impactful change that could be made for Signals. This is a very important problem to solve as it permeates through the foundations of these solutions impacting composition and control flow. In my opinion this is currently one of the few remaining gatekeepers for Signals success and I and the community are very motivated to see a solution here.
Jean-SΓ©bastien Goupil (@jsgoupil)
It doesn't necessarily have to do with impurity. But it has everything to do with composition. I do understand why one might be hesitant here. Signals are unique in that they suggest that during synchronous execution multiple reads of the same function return the same value. We shouldn't writing to signals during the pure part of execution but not all implementations do this. So I can see where one would be hesitant to give this power. But the alternative is very prohibitive. There is no way to model this behavior. Yet we can create it with JavaScript.
I don't want to force every developer to mark things as pure or idepotent. But having years of looking at the alternative in a system where you can assume every signal is, this is much more desirable to bake into base types as my expectation is every Signal in Solid would be this way. The developer just marks it as a Signal or Accessor and it gets this property. Today Developers will jump through hoops with unnecessary wrapper functions, they will use unnecessary optional chaining
?.and or non-null assertions!all over the place. I am almost certain that leads to more bugs than this would.EDIT:
I suppose though in reality Signals do get written to under certain (impure) scopes (events/side effects) so while this holds in most places unless Signals kept in the past (like React State) or threw on read after write what is being asked for isn't completely pure in all contexts. It is a like sort of like how
.valuegetters are today which probably makes this sort of thing less desirable to those maintaining TS. The thing is we are forcing our data flow semantics on JS which doesn't have them natively and it works fine until you hit TypeScript. Is it their responsibility to provide tools to address this just because it is representable in our model with JavaScript? My learnings over the last couple years have suggested that I prefer TS a basically a really smart linter and make it work for me rather than it be as accurate as possible. I'd rather it guide people in the right direction rather than be completely correct. But I'm not sure that is what TS maintainers would be after. Its a tension because this is exactly the strength of JavaScript, as it can be anything for anyone. TS probably has to be more opinionated, more narrow in what it can represent.My hope is that we can find something useful here in a general sense and then if I need to fudge a bit in my types to make it work in a practical sense I'm good with that.
JoshuaKGoldberg commentedon Jan 10, 2025
The problem with completely disabling type narrowing, even per-file, is that it's very extremely important for the type system to understand a great deal of real-world code. Disabling type narrowing would mean code like the following would produce type errors:
Turning off narrowing also wouldn't fix the TypeScript issue for signals. Signals still would struggle with narrowing computed values:
ACK that Signals are clearly painful in TypeScript right now, but I really don't think turning off type narrowing would be the solution you're looking for.
Taking a step back: for signals specifically, the root issue is really that the type system doesn't understand how the signal functions work. The types don't know that repeated parameter-less calls to a function all return the same value -- and so that value should receive the same type narrowing. In general, it's better to work towards making the type system understand a use case, not disable type system features. I filed #60948 as a starting proposal for one idea that might be able to solve this for most signals implementations.
robbiespeed commentedon Jan 19, 2025
Josh Ghoulberg π» (@JoshuaKGoldberg) I think this proposal better suites signals and general use cases. As I understand it what's being proposed is not a way to disable narrowing per file, but more so a way to mark certain functions "pure" and some getters/prop access as "impure".
I think this would work fine if fns/methods by default were considered impure, and any calls (or access) to something impure should reset all pure fn/prop (potentially local vars/lets too) narrowing. All need to be reset, because there's no telling what state an impure fn mutates.
That would allow better type safety then we have today with getters, and avoid compounding the issue of unsafe narrowing.
mkantor commentedon May 31, 2025
Also related: #49669.