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

signals: TypeScript and nullability #49161

Open
cexbrayat opened this issue Feb 22, 2023 · 35 comments
Open

signals: TypeScript and nullability #49161

cexbrayat opened this issue Feb 22, 2023 · 35 comments
Assignees
Labels
area: core Issues related to the framework runtime core: reactivity Work related to fine-grained reactivity in the core framework cross-cutting: signals cross-cutting: types
Milestone

Comments

@cexbrayat
Copy link
Member

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:

const count = signal(null as null | number);
const total: number = count() !== null ? count() : 0;
// Error: Type `number | null` is not assignable to type `number`

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:

const count = signal(null as null | number);
const value = count();
const total: number = value !== null ? value : 0;
// Works

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

@cexbrayat
Copy link
Member Author

Side note: this is not a big deal, the issue is just to trace a discussion we had with @pkozlowski-opensource on Slack

@pkozlowski-opensource pkozlowski-opensource added area: core Issues related to the framework runtime core: reactivity Work related to fine-grained reactivity in the core framework labels Feb 22, 2023
@ngbot ngbot bot modified the milestone: needsTriage Feb 22, 2023
@kbrilla
Copy link

kbrilla commented Feb 22, 2023

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

@JeanMeche
Copy link
Member

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 !

@kbrilla
Copy link

kbrilla commented Mar 7, 2023

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 >

@Harpush
Copy link

Harpush commented Mar 14, 2023

It all boils down to how deep signals should go... Theoretically you could create a computed called circleShapeRadius but I am not sure about it. It is also the point of maybe nested signals are needed?
One more option is ngIf would produce a casted signal with as.
For example a new directive (Maybe it can be done inline in template?):

<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();
    }
  });
}

@ArielGueta
Copy link

Any solution for now? this is a big issue

@flevi29
Copy link

flevi29 commented Nov 7, 2023

Seems like Angular with their new control flow wanted to get closer to JS syntax.

  • In this light the only way to extract a value from signals, observables and promises being checking against all falsy values is really ugly, not very JS syntax -y and can also be quite limiting (for instance if we have a signal that can be null, undefined, string, but we only want to check if it's not undefined, we can't, instead it won't even pass for "" empty string, this is unacceptable and ridiculous TBH).
  • It makes no sense to loop over null, undefined, document.all and call it "empty" or not err when trying to loop over 0, -0, 0n, false, NaN. The only thing that should be called "empty" is a JS array with length of 0.

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.

@using (val() as val) {
  <p>{{ val }}</p>
}

// could also have it potentially apply to any logic block that immediately follows it
@using (val() as val)
@if (val === null) {
  ...
} @else if (val === undefined) {
  ...
} @else {
  @for (valElement of val; track valElement.id) {
    ...
  } @empty {
    ...
  }
}

// could also drop `as` keyword, and infer the name from the signal name
// so we don't have to keep track of yet another variable name
@using (val()) {
  <p>{{ val }}</p>
}

It would also be very helpful to implement, through a flag, strict syntax rules for @for and whatever else needs it, so the above c**p doesn't pass anymore.

@montella1507
Copy link

Side note: this is not a big deal, the issue is just to trace a discussion we had with @pkozlowski-opensource on Slack

it is big deal. It is really HARD to use for real-world projects.

@JesseZomer
Copy link
Contributor

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>
            }
        }
    }

?
something like

        @switch ((query() as query).status) {
            @case ('pending') {
                <span>...loading</span>
            }
            @case ('error') {
                <span>Error: {{ query.error.message }}</span>
            }
        }

would be nice

@montella1507
Copy link

It is not only about nullablity... it is about type guards too.

@JesseZomer
Copy link
Contributor

@SeraphCoding Maybe I've should've included the types, but it's something along the lines of:

type queryresult = SuccesfullQuery | ErrorQuery | LoadingQuery

type SuccessfullQuery {
 status: 'succes';
 data: Data;
 error: null;
}

type ErrorQuery {
 status: 'error';
 error: Error;
 data: null;
}

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.

Signals are not observables. Don't use them as a replacement for what should be observables.

I'm not.

@montella1507
Copy link

@SeraphCoding Maybe I've should've included the types, but it's something along the lines of:

type queryresult = SuccesfullQuery | ErrorQuery | LoadingQuery

type SuccessfullQuery {
 status: 'succes';
 data: Data;
 error: null;
}

type ErrorQuery {
 status: 'error';
 error: Error;
 data: null;
}

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.

Signals are not observables. Don't use them as a replacement for what should be observables.

I'm not.

Yeah.. discriminated unions.

@compeek
Copy link

compeek commented Jan 16, 2024

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.

@yrnehli
Copy link

yrnehli commented Jan 27, 2024

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 }}
}

@SeregPie
Copy link

SeregPie commented Jan 29, 2024

We have an $any() helper available in the templates. Maybe something like this as a proxy for the signals?

@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);
}

@MikaStark
Copy link

MikaStark commented Jan 30, 2024

I played around with SolidJS and it has the same issue. The only answer to this problem is nesting @if (Show component for SolidJS) controls.

@if (somethingNullable() as something) {
  @if (something.list.length > 0) {
    <div>Something has items!</div>
  }
}

@montella1507
Copy link

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.

Yeah i wonder if someone has really tried the signals before tagging them as bright future.

@MikaStark
Copy link

MikaStark commented Jan 31, 2024

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.

[docContent]="documentContent()!.contents"
)

@csisy
Copy link

csisy commented Feb 13, 2024

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:

@let (mySignal() as myCurrentValue) {
}

The problem with the @if solution is that if you have null or any falsy value as a valid value in your signal / function call / whatever, then the condition will evaluate to false and the code section will be omitted.

@compeek
Copy link

compeek commented Feb 13, 2024

[EDIT] Angular team seems to prefer relying on non-null assertion according to angular.dev code (cf.

[docContent]="documentContent()!.contents"

)

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.

@MikaStark
Copy link

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

  • signals calls return type is inferred directly in template : nothing to change on developer side but I don’t know how Angular team can twist Typescript to make it works
  • signals « callability » is replaced by a value property which has exactly the same behavior (getter function) : requires to migrate every signal but not requires to modify anything in Angular Language Service

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.

@TimUnderhay
Copy link

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.

@stefnotch
Copy link

stefnotch commented Mar 11, 2024

Would something like this work, as a temporary fix until the Angular team gracefully decides to look at this issue?
Essentially, it replaces the Angular Signal API with something more akin to what Vue.js does. I'm not sure if this is guaranteed to work, or if it might subtly change the reactivity.

(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>

@TimUnderhay
Copy link

@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 .value to the template vars, but it would be a small price to pay.

Would you mind if I were to adopt your code into my ngx-signal-utils library at https://github.com/TimUnderhay/ngx-signal-utils ?

@TimUnderhay
Copy link

I've opened #54906 to hopefully get some attention on this from the Angular team.

@alxhub
Copy link
Member

alxhub commented Mar 16, 2024

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 data() correctly. We can go beyond TypeScript's narrowing semantics in templates because we know the value of a signal will not change between reads, as templates are executed synchronously and cannot set signals.

@csisy
Copy link

csisy commented Mar 16, 2024

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?

@if (let data = data(); data > 5) {}
@if (let data = data()) {}

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.

@stefnotch
Copy link

@csisy
For what it's worth, Svelte has a @const statement to define a local variable inside a template. That might be a more general and convenient solution.

https://svelte.dev/docs/special-tags#const

I personally think that implementing such a @const wouldn't make the improved narrowing obsolete in any way. And neither would the improved narrowing take care of all the use-cases for a template-local variable definition.

@Harpush
Copy link

Harpush commented Mar 16, 2024

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 (@const as mentioned here for example). In simple cases the compiler auto narrow is enough but sometimes in large objects it makes sense to create a variables and use it multiple times.
Let's assume we wanted to write shape().width | number and use it three times - interpolation, pass it as input and in a tooltip. Could be nice if I could assign it to a variable as use that one three times.

@flevi29
Copy link

flevi29 commented Mar 16, 2024

@const might also work better with AsyncPipe as well, maybe. Anyways this is the closest thing to what I was proposing #49161 (comment) .

@JeanMeche
Copy link
Member

Template variable declaration is a separate topic and is tracked by #15280 🙂

@c-harding
Copy link

c-harding commented Apr 25, 2024

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 address.value never changes, unlike address(). Is there any way of convincing TypeScript that type narrowing works across signal gets?

@JeanMeche
Copy link
Member

@c-harding, this issue is being investigated, please have a look at #55456

@c-harding
Copy link

@JeanMeche that PR is only for templates rather than in TypeScript

@Eduard20CR
Copy link

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]);
    })
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: core Issues related to the framework runtime core: reactivity Work related to fine-grained reactivity in the core framework cross-cutting: signals cross-cutting: types
Projects
Status: In Progress
Development

No branches or pull requests