Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow type narrowing to be specified as always-on or always-off #57725

Open
6 tasks done
matthew-dean opened this issue Mar 11, 2024 · 14 comments
Open
6 tasks done

Allow type narrowing to be specified as always-on or always-off #57725

matthew-dean opened this issue Mar 11, 2024 · 14 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@matthew-dean
Copy link

matthew-dean commented Mar 11, 2024

πŸ” Search Terms

type narrowing of functions, asserts on getters

βœ… Viability Checklist

⭐ 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

  1. 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.

  1. 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.

  1. What workarounds are you using in the meantime?

A very clumsy workaround is using a Vue computed(), which then also type-narrows.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Mar 11, 2024
@fatcerberus
Copy link

This seems like a use case for pure annotations. 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
Copy link
Author

@fatcerberus I agree. But, likewise, there is a need for impure annotations from unstable properties, or Proxies etc.

@eneajaho
Copy link

This would solve Angular signals type narrowing too angular/angular#49161

@eneajaho
Copy link

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
Copy link

This will make reactive programming in Angular smoother. I support.

@eneajaho
Copy link

eneajaho commented Dec 17, 2024

Related issues/discussions:

@Juamedrod
Copy link

Someone had to say it. This help angular signals assertion in templates.

@arnoud-dv
Copy link

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.

TypeScript currently does not support discriminated unions on object methods. Narrowing on signal fields on objects such as query results only works on signals returning a boolean. Prefer using isSuccess() and similar boolean status signals over status() === 'success'.

https://tanstack.com/query/latest/docs/framework/angular/typescript#type-narrowing

@arnoud-dv
Copy link

arnoud-dv commented 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
Copy link

jsgoupil commented Dec 19, 2024

I love how @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 parentheses syntax(), 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 @Idempotent attribute as well?

@JeanMeche
Copy link
Contributor

JeanMeche commented Dec 28, 2024

So why are we still using () with signals and functions? The answer lies in their impurity

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
Copy link

ryansolid commented 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 .value ie 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.

function toGetter(fn) {
  return {
    get value () {
      return fn();
    }
  }
}

const doubleCount = toGetter(() => count.value * 2)

Of course you can make a computed or 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:

const getCount = () => countSignal.value

getCount(); // can be tracked reactively regardless of your syntax

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.

@jsgoupil

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.

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 .value getters 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
Copy link
Contributor

JoshuaKGoldberg commented 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:

function logMaybe(maybe: string | undefined) {
  if (!maybe) return;
  console.log(maybe.toUpperCase());
  // With type narrowing: no error
  // Without type narrowing: 'Object is potentially undefined.'
}

Turning off narrowing also wouldn't fix the TypeScript issue for signals. Signals still would struggle with narrowing computed values:

declare const value: () => string | undefined;

if (value() !== undefined) {
  value();
  // Type: string | undefined

  console.log(value().toUpperCase());
  //          ~~~~~~~ Object is possibly 'undefined'.
}

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.

Thanks @fabiospampinato and @ryansolid for helping me understand the problem space & ideating on an ideal solution in https://x.com/fabiospampinato/status/1876354827959083085! πŸ™

@robbiespeed
Copy link

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests