-
Notifications
You must be signed in to change notification settings - Fork 24.8k
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
signals: TypeScript and nullability #49161
Comments
Side note: this is not a big deal, the issue is just to trace a discussion we had with @pkozlowski-opensource on Slack |
Won’t this work: const count = signal(null as null | number);
const total: number = count() ?? 0; or maybe add mapper count((val)=> val !== null ? val : 0) but this would not work well in templates as there is no support for arrow functions there |
The issue is more about narrowing. In a template with
// count still number | null.
That wouldn't happen if the value wasn't acced via a function ! |
Well there is not only a nullability problem but any kind of type guard type Shape = {kind: 'Circle', r: number} | {kind: 'Square': a: number};
shape= Signal<>; <span *ngIf="shape().kind === ''Circle">
This is a circle with r={{shape().r}} //type error
</span> You would need to do <ng-container *ngIf="shape() as shape"> //ngIf hack as with | await
<span *ngIf="shape.kind === 'Circle'">
This is a circle with r={{shape.r}} //now ok
</span>
</ng-container> The same can be used for null | undefined as long as we are not expecting any of '', 0, false so null | undefined is bigger problem as no full workaround for it <ng-container *ngIf="shapeOrNull() as shape"> //ngIf hack as with | await
<span *ngIf="shape">
This is a circle with r={{shape.r}} //now ok
</span>
</ng-container> <span *ngIf="numberOrNull() as num"> //will not show answer work for 0
This number is {{num}}
</span > |
It all boils down to how deep signals should go... Theoretically you could create a computed called <div *ngSignalIf="let circleShapeSignal when shapeSignal match checkFn">
Radius is: {{circleShapeSignal().radius}}
</div> function checkFn(shape: Shape): shape is CircleShape {
return shape.kind === 'circle';
} And simplified code to how it works: type Shape =
| { type: 'circle'; radius: number }
| { type: 'rectangle'; size: number };
const shapeSignal = signal<Shape>({ type: 'circle', radius: 10 });
function test<T, K extends T>(
signal: Signal<T>,
checkFn: (value: T) => value is K
) {
const castedSignal = computed(() => {
const value = signal();
return checkFn(value) ? value : undefined;
});
effect(() => {
const casted = castedSignal();
if (casted) {
renderView(castedSignal); // pass context to template
} else {
destroyView();
}
});
} |
Any solution for now? this is a big issue |
Seems like Angular with their new control flow wanted to get closer to JS syntax.
I think this is a place where being explicit rather than implicit is the right way. And for that the syntax I'm proposing would be very helpful.
It would also be very helpful to implement, through a flag, strict syntax rules for |
it is big deal. It is really HARD to use for real-world projects. |
Lets say I have the following template: (query is either a succesfull response or an error response type with an error field) @switch (query().status) {
@case ('pending') {
<span>...loading</span>
}
@case ('error') {
<span>Error: {{ query().error.message }}</span>
}
} Currently this is not allowed because error might be null. Of course I could do error(?/!).message but I'm wondering if there is another way. Is the only other alternative currently: @if (query(); as query) {
@switch (query.status) {
@case ('pending') {
<span>query.status</span>
}
@case ('error') {
<span>Error: {{ query.error.message }}</span>
}
}
} ? @switch ((query() as query).status) {
@case ('pending') {
<span>...loading</span>
}
@case ('error') {
<span>Error: {{ query.error.message }}</span>
}
} would be nice |
It is not only about nullablity... it is about type guards too. |
@SeraphCoding Maybe I've should've included the types, but it's something along the lines of:
so checking for status 'error' is the same as checking if error exists or not. The whole problem is that I want to get rid of that extra if, because it shouldn't need to be there. It should narrow the query result to ErrorQuery.
I'm not. |
Yeah.. discriminated unions. |
I ran into this problem almost immediately when I started trying out signals. I also think it's crucial that Angular somehow support type narrowing for signals in templates. It would improve the developer experience tremendously. |
There is a hacky workaround to this, which is using a getter function to wrap the signal value. type LoadState<T> = { state: "idle" } | { state: "loaded", data: T }
export class AppComponent {
public _num: WritableSignal<LoadState<number>> = signal({ state: "idle" });
public get num() {
return this._num();
}
} // Property 'data' does not exist on type 'LoadState<number>'.
// Property 'data' does not exist on type '{ state: "idle"; }'
@if (_num().state === 'loaded') {
{{ _num().data }}
}
// This works fine
@if (num.state === 'loaded') {
{{ num.data }}
} |
We have an @Component({
template: `
@if ($.foo != null) {
<span>{{ $.foo | myNumberFormatter }}</span>
}
`,
})
export class MyComponent {
foo = input<null | number>(null);
$ = unsignalify(this); // available by default
} Could be done with something like this. import {Component, Signal, isSignal} from '@angular/core';
export type UnwrapSignal<T> = T extends Signal<infer V> ? V : T;
export type UnsignalifyProxy<T extends object> = {
[K in keyof T]: UnwrapSignal<T[K]>;
};
const unsignalifyProxyHandler: ProxyHandler<any> = {
get: (target, key, receiver) => {
const value = Reflect.get(target, key, receiver);
return isSignal(value) ? value() : value;
},
set: (target, key, newValue, receiver) => {
// todo
},
};
export function unsignalify<T extends object>(target: T): UnsignalifyProxy<T> {
return new Proxy(target, unsignalifyProxyHandler);
} |
I played around with SolidJS and it has the same issue. The only answer to this problem is nesting @if (somethingNullable() as something) {
@if (something.list.length > 0) {
<div>Something has items!</div>
}
} |
Yeah i wonder if someone has really tried the signals before tagging them as bright future. |
The problem arises from the need to "call" signals to retrieve their values, preventing TypeScript from effectively type guarding the returned value. I believe this is more of a design flaw than an inherent issue. If signals had a "value" getter, this problem would likely be mitigated. @if (somethingNullable.value && somethingNullable.value.list.length > 0) {
<div>Something has items!</div>
} (https://stackblitz.com/edit/stackblitz-starters-kpu7je?file=src%2Fmain.ts) [EDIT] Angular team seems to prefer relying on non-null assertion according to angular.dev code (cf.
|
I could live with a solution that would solve other, non signal related problems: Provide a built-in mechanism to create a scoped variable in a template without using an if statement, which is the current community solution. Something like this:
The problem with the |
That's a good point. I guess the non-null assertion operator is a workaround for now, but it's prone to mistakes, and I'm in the habit of avoiding it unless absolutely necessary, so I'm still hoping for a more proper solution. |
@csisy your proposal could seems appropriated but IMO it would leads to a nesting nightmare if used with multiples signals just like @if control flow with « as » aliasing does already. For me, there is two viable options :
I think Angular Signals are too much inspired from solidjs but the frameworks are so much different. Thanks to JSX, solidjs provides the possibility to use a callback directly in control flow components to extract a signal value as good old variable and so mitigate this typing issue. Definitely, the core problem is the « callability » of signals. |
After working with signals for a while now, I'm convinced that the issue is the underlying design choice to make signal unwrapping function-based rather than getter-based. This choice is forcing developers to either write verbose, ugly, harder to maintain template code, or to write less safe code by using ! or just switching off strict null checks and / or strict templates altogether to make the red ocean of bogus template errors go away. Please Angular team, reconsider this design choice. It isn't too late. |
Would something like this work, as a temporary fix until the Angular team gracefully decides to look at this issue? (CC0 licensed code) type Ref<T> = {
value: T;
update: (mapFn: (old: T) => T) => void;
asReadonly: () => ReadonlyRef<T>;
};
type ReadonlyRef<T> = {
readonly value: T;
asReadonly: () => ReadonlyRef<T>;
};
function makeRef<T>(signal: WritableSignal<T>): Ref<T> {
return {
get value() {
return signal();
},
set value(newValue: T) {
signal.set(newValue);
},
update<U extends T>(mapFn: (old: T) => U) {
signal.update(mapFn);
},
asReadonly(): ReadonlyRef<T> {
return {
get value() {
return signal();
},
asReadonly() {
return this;
},
};
},
};
} Usage examples @Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.css"],
})
export class AppComponent {
count = makeRef(signal<number | null>(10));
nextValue = computed(() =>
this.count.value !== null ? this.count.value + 1 : 0
); // Look at how this just works
update(event: Event) {
const input = event.target as HTMLInputElement;
this.count.value = parseInt(input.value);
}
} <!-- Typescript is happy! -->
<p>Next value with ngIf:</p>
<p *ngIf="count.value !== null">{{ count.value + 1 }} cups</p> |
@stefnotch Your solution sounds quite plausible, though I also wonder if it will subtly alter the reactivity. I suspect that it won't as signals are, for the moment, still dependent on change detection, and the getter references should be equal to the unwrap function's. But it would satisfy Typescript's nullability mechanism. The only downside is that unlike Vue, one would have to add Would you mind if I were to adopt your code into my ngx-signal-utils library at https://github.com/TimUnderhay/ngx-signal-utils ? |
I've opened #54906 to hopefully get some attention on this from the Angular team. |
Hey all! Type narrowing of signals in templates is a pain point at the moment, and is on our backlog. I've updated our project tracker to track this issue directly to make the progress more visible. In the original design, we understood there would be challenges with type narrowing and function-based signals. We strongly preferred the functional design anyway (for reasons spelled out in the RFC) and hypothesized that we could address the DX challenges with narrowing using our compiler, which is the plan. At a high level, our template type-checking engine can apply extra type narrowing to signals, so that the templates: @if (data()) {
{{data().value}}
} or @switch (shape().kind) {
@case ('circle') {
Radius: {{shape().radius}}
}
@case ('square') {
Width: {{shape().width}}
Height: {{shape().height}}
}
} will narrow the type of |
What do you think about extending the new control flow syntax a bit? Recently I've been using cpp where we can initialize variables inside the conditional part of the if statement. For example: if (auto myValue = ...; myValue > 5) {
// executed if myValue > 5
}
// or
if (auto myValue = ...) {
// executed if myValue is "truthy"
} With signals and the new control flow syntax, is it possible to implement something similar?
Not sure if this would be harder to implement than the type narrowing you mentioned. It would have use-cases outside of signals, where we want to render something conditionally. However, I'm not sure how this could handle switch statements like you've written in the example. |
@csisy https://svelte.dev/docs/special-tags#const I personally think that implementing such a |
I believe it is a tricky question... The compiler automatic type narrowing Alex mentioned is indeed handy and will solve the problem (any ETA btw?). What remains a bit of an issue is a case when we have nested checks or long properties access path. It might be nice having some way to alias in template ( |
|
Template variable declaration is a separate topic and is tracked by #15280 🙂 |
I’ve just encountered this problem outside the template, in a computed signal: computed(() => this.address() && this.getAsString(this.address())) // error TS2345: Argument of type Address | undefined is not assignable to parameter of type Address I could have written: computed(() => {
const address = this.address();
return address && this.getAsString(address)) but that seems unnecessarily verbose. With Vue this was never a problem, because TypeScript assumes that |
@c-harding, this issue is being investigated, please have a look at #55456 |
@JeanMeche that PR is only for templates rather than in TypeScript |
I have an issue, I need to set the value of the signal in the AfterViewInit because I need the component to be ready, after I check if the values is not undefined, it keeps saying it may be undefined. stage: WritableSignal<Konva.Stage | undefined> = signal(undefined);
ngAfterViewInit(): void {
this.initStage()
}
initStage() {
const width = this.container.nativeElement.offsetWidth;
const height = this.container.nativeElement.offsetHeight;
this.stage.set(new Konva.Stage({
container: 'container',
width,
height,
scale: { x: this.getScale(), y: this.getScale() },
}))
if (!this.stage()) return; // -> 'stage' is possibly 'undefined'.
stage.on('click', (e) => {
this.transformer().nodes([]);
if (e.target === this.stage()) return;
if (e.target instanceof Konva.Image) return;
this.transformer().nodes([e.target]);
})
} I fixed it by putting stage in a local variable, but I dont think it is a great solution, am I doing something wrong? stage: WritableSignal<Konva.Stage | undefined> = signal(undefined);
ngAfterViewInit(): void {
this.initStage()
}
initStage() {
const width = this.container.nativeElement.offsetWidth;
const height = this.container.nativeElement.offsetHeight;
this.stage.set(new Konva.Stage({
container: 'container',
width,
height,
scale: { x: this.getScale(), y: this.getScale() },
}))
const stage = this.stage()
if (!stage) return;
stage.on('click', (e) => {
this.transformer().nodes([]);
if (e.target === this.stage()) return;
if (e.target instanceof Konva.Image) return;
this.transformer().nodes([e.target]);
})
} |
Which @angular/* package(s) are the source of the bug?
core, Don't known / other
Is this a regression?
No
Description
The current implementation of signals uses a function to access the value, and TypeScript has issues with type guards in that case:
This can be workaround by storing the value in a temporary variable or with an explicit cast, but this could be a bit cumbersome, as I imagine the same would happen with
ngIf
in templates:Please provide a link to a minimal reproduction of the bug
No response
Please provide the exception or error you saw
No response
Please provide the environment you discovered this bug in (run
ng version
)No response
Anything else?
No response
The text was updated successfully, but these errors were encountered: