Repository navigation
'identity' modifier to indicate a function's parameter-less returns should be narrowed like a valueΒ #60948
Description
π Search Terms
indicate function returns identical value cached memoized signals
β 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
Many apps today are built on the concept of using small "getter" functions as wrappers around values. For example, Signals as implemented in Angular, Solid, and the stage 1 TC39 Signals proposal often look something like:
declare const value: () => string | undefined;
if (value() !== undefined) {
console.log(value().toUpperCase());
}Signals users have struggled with using them in TypeScript because, at present, there isn't a way to get that code block to type check without type errors. Signals users know that the result of value() must be string inside the if, but TypeScript doesn't have a way to note that the result should be type narrowed. Common workarounds today include !, ?., and refactoring to store intermediate values. All of which are at best unnecessary verbosity, and at worst conflict with frameworks.
Request: can we have a keyword -or, failing that, built-in / intrinsic type- to indicate that calls to a function produce a referentially equal, structurally unchanging value? In other words, that the function call (value()) should be treated by type narrowing as if it was just a variable reference (value)?
Proposal: how about an identity modifier keyword for function types that goes before the ()? It would be treated in syntax space similarly to other modifier keywords such as abstract and readonly.
π Motivating Example
When an identity function is called, it is given the same type narrowing as variables. Code like this would now type check without type errors, as if value was declared as const value: string | undefined:
declare const value: identity () => string | undefined;
if (value() !== undefined) {
value();
// Before: string | undefined
// Now: string
console.log(value().toUpperCase());
// Before: ~~~~~~~ Object is possibly 'undefined'.
// Now: β
}Narrowing would be cleared the same as variables when, say, a new closure/scope can't be guaranteed to preserve narrowing:
declare const value: identity () => string | undefined;
if (value() !== undefined) {
setTimeout(() => {
value();
// Still: string | undefined
console.log(value().toUpperCase());
// Still: ~~~~~~~ Object is possibly 'undefined'.
});
}π» Use Cases
One difficult-to-answer design question is: how could identity handle functions with parameters? I propose the modifier not be allowed on function signaturess with parameters to start. It should produce a type error for now. The vast majority of Signals users wouldn't need signatures with parameters, so I don't think solidifying that needs to block this proposal. IMO that can always be worked on later.
Furthermore, it's common for frameworks to set up functions with a parameter-less "getter" signature and a single-parameter "setter" signature. I propose for an initial version of the feature, calling any other methods or setting to any properties on the type should clear type narrowing:
declare const value: {
identity (): string | undefined;
(newValue: string | undefined): void;
}
if (value() !== undefined) {
value("...");
value();
// Still: string | undefined
console.log(value().toUpperCase());
// Still: ~~~~~~~ Object is possibly 'undefined'.
}More details on the difficulties of signals with TypeScript:
- Angular: signals: TypeScript and nullabilityΒ angular/angular#49161
- Solid: Signals are troublesome with TypescriptΒ solidjs/solid#1575
If a new modifier keyword isn't palatable, a fallback proposal could be a built-in type like Identity<T>. This wouldn't be a new utility type (FAQ: no new utility types); it'd be closer to the built-in template string manipulation types.
Activity
fabiospampinato commentedon Jan 10, 2025
Keeping the narrowing inside the
ifseems problematic if any function could be executing before our second call tovalue()here, as that could result in the type checker telling us something incorrect:Also this working only in getter/setter-style signals is potentially limiting, for example I use unified signals, with a single function that is both a getter and a setter, because there are many benefits to doing that in a typed codebase (which essentially prevents the common issues of this approach from happening), and if this worked only for getters it would be of no use to me, basically.
In general the deeper problem seems about somehow detecting when various types of narrowing should be invalidated, which seems very hard to express and very difficult for the type checker to check for.
Worth exploring this area though, as signals are getting more popular.
Andarist commentedon Jan 10, 2025
The same already happens with property narrowing so this wouldn't be a new problem at all. This would just use the same tradeoffs as the ones mentioned in #9998
RyanCavanaugh commentedon Jan 10, 2025
Isn't the core problem in this example that
valueis incorrectly declared? If it wereThen there's a very straightforward path to adding a narrowing rule that allows a normal interpretation of narrowing
valueto the() => stringconstituent.RyanCavanaugh commentedon Jan 10, 2025
Also an ELI5 explanation for why it's not correct to write
const v = value();would be appreciatedalxhub commentedon Jan 10, 2025
Such a type might work for the simple case, but isn't really generalizable. If you consider the most basic implementation of a signal-like type:
it would be very difficult / infeasible to type
getinto a union of all possible function types expressing the different narrowed shapes ofT, without knowing the specificT. This is also a combinatorial problem: ifTis something like{id: string|number, value: Data|undefined}, bothidandvaluecan be narrowed independently and encoding that into thegettype would result in 4 different function types (assuming that even works).It's overhead compared to the experience with plain properties. Sometimes you have multiple levels of operations or multiple reads, which would result in a proliferation of temporary variables.
More critically, while
value()is stable in the sense that repeated calls will return the same value, it may not be side-effect free. In the following chain:it might be important to record that
x()is invoked aftercreateNewContext(). Extracting to a temporary variable fixes the narrowing, but may alter the behavior in a breaking way.RyanCavanaugh commentedon Jan 10, 2025
A distributive conditional type would be correct, though you would only be able to narrow a union (
string | number), not a specific value (ifTisstringyou still couldn't narrow to0)ryansolid commentedon Jan 10, 2025
Since where the read(function execution) happens matters in Signals libraries hoisting is incredibly clunky for a lot of cases. Especially in templating. Like think of JSX where everything is an expression not a statement. Most templating languages are effectively similar. Signal libraries tend to be granular in their rendering so components/templates don't re-run on a whole. Only parts that change re-execute. So the Signal function needs to be accessed in a very specific scope to trigger the right execution.
Not sure of people's familiarity but this is why often these sort of libraries can't destructure props. Because you can't access the getters at the top of the component but instead in the expression closest to where they are used. Different problem but part of the same mechanisms that are present here.
You don't always have a place to define variables and access the signal that might need to be used way nested down in your template. Places that are conditionally rendered, or parts of loops. A map function atleast can be made a block statement to be fair but even inside it there will be nested expressions so it can become onerous.
This is a fundamental aspect of Signals and the more granular people leverage them the more inevitable it will come that it will be painful to try to hoist stuff.
Monkatraz commentedon Jan 10, 2025
How do you actually write this in non-declaration code?
This fails to type, and doesn't actually put
geton the prototype:You can do this instead, but
valueis considered unused, and it looks a little absurd:As a side-note, Solid calls these kinds of functions
Accessorsalxhub commentedon Jan 10, 2025
This also only works for types in the top-level union. If a property of
Tis itself a union, it can't be narrowed via selecting a specific variant ofPossibleFuncs<T>.RyanCavanaugh commentedon Jan 10, 2025
Wait, if the idea is that
get"always returns the same value", why is there asetmethod in the first place? Doesn't this imply the need for additional syntax to identify which functions/methods invalidate the narrowing?Monkatraz commentedon Jan 10, 2025
I mean, this violates the spirit of your argument as well:
Monkatraz commentedon Jan 10, 2025
I suppose if you're building with explicitly TypeScript in mind the ideal implementation is something like this:
But this wouldn't work for e.g. Solid, which just has accessors like
foo()with no property access. Also, most consumers of the signal library won't be given signals directly and will instead have something wrapped around them, likeuseStatein React orcreateSignalin Solid, both of which do not return an object that you do property access on.EDIT: Also, the explicit function call syntax (so
signal.get()) gets across that the access actually has side effects a little better.RyanCavanaugh commentedon Jan 10, 2025
Okay, but it seems extremely nearsighted to ship a new CFA feature that is going to immediately run into another feature request before it's considered useful. Bringing up the current CFA trade-offs as relates to properties doesn't seem like a useful way to address this concern.
Monkatraz commentedon Jan 10, 2025
You're not wrong, but I think the feature would still be helpful regardless. The trade-offs are a "known quantity" to me, and this doesn't introduce anymore of them, right? It would be basically identical to how getters behave (except with no setter, I guess)
EDIT: I think with this feature it would be important for the naming and docs to get across it's intended for accessors, so as long as you don't yield to anything, you will know what the value is and it can't change on you (...usually), like a property.
Monkatraz commentedon Jan 10, 2025
As an interesting anecdote, Svelte's old style of stores basically handled this problem via the magic
$storesyntax:Kind of like Signals, Svelte's compiler would track where you used these magic accesses. The Svelte LSP would basically trick the TypeScript LSP into thinking
$valuewas a real variable iirc, and so it would naturally handle narrowing. It would even handle you setting the store's value, which I'm not sure will ever be achievable with any kind of[get, set] = whatever()pattern without some pretty substantial additions to TypeScript.EDIT: There is also an old references proposal for JS which also touches on this problem, specifically this issue about making the concept of references extensible. Food for thought.
11 remaining items
shicks commentedon Jan 17, 2025
Good points above about how
mutating () => voidis kinda meaningless, but the example of(which I think is necessary) would suggest that
stable () => Tis probably a meaningful type on its own, which would just never be invalidated once it's torn off.Other interesting use cases to consider:
if (message.getFoo()) useNonNull(message.getFoo());stable () => Foo|undefined.robbiespeed commentedon Jan 18, 2025
I'm really not sure about this proposal, there's a
50/50near 0% chance I'd use it in my signals lib. Technically it makes things less type safe which I do not like at all. It improves DX in a lot of cases, but at the risk of making it much more confusing/bug prone in other cases.Example of a confusing case that could be quite common:
Signals are built for the purpose of composing state derivation. Getters have the same issue, but I don't think it would be as commonly encountered.
Further narrowing after a dependency has mutated would also become very problematic.
Even with casting to allow the condition the narrowed type never changes.
There is a work around but most users are not going to know how to do it.Originally thought this was a work around, but no it just makes the type
never.These aren't new problems, they exist with getters (TS playground). However I do think it's far more likely to run into the issue with signals.
I'd much rather encourage users to assign the value to a local variable:
Kinda wish there was a strict option to disable property access narrowing, and if this lands I'd defiantly want a way to disable it for my own projects.
ryansolid commentedon Jan 19, 2025
I understand being tooling authors/maintainers we are accustomed to thinking about edge cases. Because let's face it, if we make something possible people will abuse it.
stableis significantly more important thanmutating. I get why mutating is challenging, because the way the reactive graph works a change of one thing could invalidate others and there is no reasonable way for TS to know that. I'd takemutatingbecome the nuclear option(ie, invalidates allstable) before giving upstable.The reason is with Signals mutation for the most part(if not always) should be limited to specific scopes. Pure computation(derivations) shouldn't mutate. This limits it to event handlers, and denoted effectful portions of the code. Code that is effectively imperative. Code where block statements exist and reactive scope does not matter. Not only is this a much smaller fraction of the code(like 10% maybe) but it is place where you can set a local variable.
But the other 90% of the code where there is no mutation, where templating logic lives, where primitives compose, where you build the graph, and where assigning local variables is unreasonable benefit immensely from
stable.I'm also fine with the current proposal because we are talking about such a small slice of a slice. It is consistent with getter/setters today and atleast this way it is explicit and can apply to other functions.
robbiespeed commentedon Jan 19, 2025
Ryan Carniato (@ryansolid) Can you give an example outside of templates where assigning to a local variable is unreasonable? I do not think we should be creating a feature which has it's primary use case tailored to non standard template syntax, where something like a TS language service plugin could do the work.
My concern is this changes the failure mode of signals in block scope reactive areas from a non narrowed type safe annoyance, to one which reduces type safety and can introduces runtime errors. Users are unlikely to know TS is producing incorrect types from overly optimistic narrowing, so they wouldn't do the variable assignment to workaround.
Not all signals libs have restriction about reactive scopes, in some it's totally fine to mutate signals anywhere even inside derivations, and reactivity can remain stable and active. Even still just considering event handler contexts I think this would be enough of a footgun there.
This change has the potential to steer signal libs into queuing reactive propagation till after event handlers, since that would remove the type safety issue of mutations affecting derivations.
#57725 (pure/impure fn/accessor distinction) has the potential to allow a safe way for narrowing signals, and improve the safety of getter/prop narrowing.
fardolieri commentedon Jan 19, 2025
Wouldn't Ryan's suggestion (using function unions) address most of the
stableproblem?The
mutatingpart is the tricky one. Has anyone explored the idea of extending type predicates or type guards to allow changing properties ofthis?shicks commentedon Jan 19, 2025
Nuclear option for
mutatingsounds like it might be the most reasonable option, and would alleviate many of the concerns about mutating signals, given how common derived signals are likely to be. That said, there are tons of instances where type narrowing chooses ergonomics over soundness. For example, the fact that array access is potentiallyundefined, bivariance on overridden method signatures, or type narrowing on nested properties. These "not 100% stable" concerns seem to be of roughly similar consequence.alxhub commentedon Jan 20, 2025
This is generally how we see this feature. It's always possible to construct examples where TypeScript's soundness falters, but our experience with signals so far mirrors Ryan Carniato (@ryansolid)'s - this tends not to be an issue in everyday code.
Re: Robbie Speed (@robbiespeed)
For what it's worth, we explored the idea of implementing narrowing as a feature of our language service pretty extensively, and concluded that it was infeasible. The only workable way to achieve the narrowing semantics is to transform incoming expressions and extract the result of stable function calls into temporary variables. This assumes that you can know which of those function calls are stable (are signal reads), which is already not possible without running through type-checking already. We were even willing to treat all zero-arg calls as stable, but it turns out this variable extraction is difficult to impossible to do while preserving all the other type flow operations and semantics.
Additionally, we generally want TypeScript to behave the same inside and outside of templates - narrowing within
computedsignals is just as important as narrowing within templates.dummdidumm commentedon Jan 20, 2025
I argee that
stableon its own makes sense, too. For example if you only pass the getter function into another function.c-harding commentedon Feb 4, 2025
Robbie Speed (@robbiespeed) for me the big one is with one-liner functions, such as with the
computedfunction implemented by Vue and Angular.Vueβs solution, where reading a ref is done by getter, typechecks:
Angularβs solution, where reading a signal is done by function, does not typecheck:
To rewrite this to typecheck, it would have to be:
or
andreialecu commentedon Feb 5, 2025
A case I havenβt seen mentioned that could introduce complexity, potentially from a performance perspective as well:
This can easily lead to random function calls creating TS errors without an obvious reason, making debugging difficult.
That said, Iβd really like to see some improved DX around this scenario, and I hope a solution is found.
A feature like assignment in a conditional expression (similar to what C++, Swift, or even PHP supports) might have helped here:
JeanMeche commentedon Feb 5, 2025
Typescript is not intended to create new syntactic sugar.
Those are developed at the platform level, see https://github.com/tc39/proposal-Declarations-in-Conditionals.
andreialecu commentedon Feb 5, 2025
I know; I was mainly pointing out in that direction as a possible solution.
It may be time to revive that proposal and fast-track it. π
shicks commentedon Feb 5, 2025
Andrei Alecu (@andreialecu) wrote:
To make this work well, I think
doBarwould need to be locally/explicitly tagged asmutating(rather than expecting the type checker to understand the transitive behavior automatically), which further suggests thatmutatingprobably wants to be global rather than tracked per-signal, since otherwise you get into a morass of specifying "what does this mutate?" Ideally you'd also want a check thatmutatingis viral: calling themutatingthis.val.setfrom a non-mutatingdoBarwould be an error, though pragmatically I suspect this would make any newmutatingannotations nearly-impossible to land; I don't know what the sweet spot would be.JeanMeche commentedon Mar 11, 2025
Daniel Rosenwasser (@DanielRosenwasser) What would be the next step for the dicussion ?
Do you still expect more feedback ?
csvn commentedon Oct 1, 2026
Been pretty quiet here for a year. Has there been any internal discussion on this issue?
Just thought of this again when using a common pattern with signals, that can't be narrowed correctly due to param-less getter function:
@switch (signalValue()) { @case (1) { One } @case (2) { Two } @default never; // signalValue() is not narrowed, so compiler error fails build }