Diving into Type System behind Angular Signal Inputs
Tomasz Ducin
Posted on December 28, 2023
Original version of this writing has been published as a twitter thread. However, I was convinced by @eneajaho to convert it into a blogpost π. I decided to leave the original form: quick, easy-going and concise.
As you may know, Signal Inputs are coming to Angular in mid January 2024 in version 17.1. But we can actually play with them already, or at least, take a look at their almost public API. In this post we'll take a deep dive into the type design of Signal Inputs internals, its consequences, and a lot of examples of Signal Inputs usage.
Demo code
We're gonna expose what the Angular Team has hidden from us...
πͺ the trick is:
import { Ι΅input as input } from '@angular/core';
don't do this at home π!
Following stackblitz allows you to see Signal Input declarations. Note that there's no Signal Input implementation within angular as of now (v17.1.0-next.5
), so we'll focus on declarations/public API only.
ToC
π οΈ first - examples
π₯© then - internals
We'll analyze the examples from above stackblitz one by one, and later we'll analyze the internals & types underneath.
Examples
We'll start with lots of examples. Take a look at the first one:
π initial value is used to infer the input's type, same as with ordinary signals:
β οΈ Input Signals are READONLY, which makes sense, since it's the parent component who can put new values (via templates) into the input signal, not us π
One of my favorite design decisions:
πͺ taking advantage of (1) #typescript's possibilities and (2) applying best solutions from other frameworks. In this case: from React
ππ©·π love it π©·ππ©·
Less exciting - and IMHO it should be avoided as much as possible (since it decreases readability and the understanding of how components exchange data) - aliasing is allowed:
π‘ aliased name is used by parent to pass the data
π the prop name is used internally (obviously)
Another logical design decision:
πͺ since an input is REQUIRED, passing initial value makes no sense π so it's removed from the signature
Here the initialValue
parameter is removed from the function (overload) signature. Or to be more precise - it's never added (never allowed) to be there π
So, technically speaking, TypeScript would try to pass your initial as options object which obviously fails.
And one more example - transform
is allowed only when passing both ReadT
and WriteT
type parameters. More on this later...
Internals
Now let's see π some internals π₯©
To get a broad overview of the direction, you might be interested in taking a look at the β‘οΈ Angular Team's Sub-RFC 3: Signal-based Components which deals with, more or less, how to make use of signals in Angular Components. Also, you can β‘οΈ take a look at the code itself. Anyway, let's dive into the internals!
We've got 2 new signal symbols:
it's the same usecase, as with the former unique Signal symbol - it allows to restrict compatibility across different (input) signals.
For instance:
β
one can assign the InputSignal<number, number>
to an ordinary Signal<number>
Proof:
need to make ordinary signal READONLY so that TS compares 2 readonly signals (input signals are READONLY)
where a string signal is expected, a string input signal is acceptable, since it has all required (needed) properties.
β but the other way round it fails, since an ordinary string signal doesn't have the brand read/write signals.
Reassigning signals, however, is not going to be anyhow common π. But the Angular compiler also makes use of the symbols internally.
Now, this is where we get the signals API from. There's a completely separate implementation for optional and required inputs. It's just exposed as a convenient API:
s1 = input()
s2 = input.required()
The sad thing, however, is that currently we cannot see its runtime.
(disclaimer: as mentioned above, ng v17.1 is expected very soon, this analysis uses v17.0.1-next.5
).
Now let's take a look back at input transform:
However, you might be wondering what the heck is going on with this function overload and the strange declaration at the bottom:
First of all, if we pass <ReadT>
only, we can't pass the input transform (that's the same as with current
@Input
({ transform: ... })
However, if we pass both <ReadT, WriteT>
, we can additionally pass the transform. Note that:
-
ReadT
is the expression type you want to use inside your component -
WriteT
is the expression type that is passed from outside
Another example:
Going back to our nice declaration π₯΄ let's focus on inputFunction
first:
It has 3 overloads on its own:
- take no initial value and extend inner value with undefined
- take the initial value with
ReadT
type param only (opts WITHOUT transform) - take the initial value with both
ReadT, WriteT
type params (opts WITH transform)
All usecases seen before ππͺ
All in all we've got 3 overloads for the prop = input()
(optional)
... and 2 overloads for the prop = input.required()
(required)
Phew π that was quite a lot. Hope you enjoyed that.
Conclusion
We've seen quite a few examples on how certain usage of input signals affect underlying TypeScript types. Each usage depends on your specific needs in a specific situation. However, you should always pay attention to what types get declared/inferred in each case, as type-safety is one of the most important factors that form overall code quality.
Underlying Signal Input types are very well defined, but it's always your responsibility to verify, whether a given input should be optional or required in the long run. Types should only reflect your design.
Remember you can play with it on this stackblitz. It includes the code of all examples.
Follow me on twitter for more frontend (js, ts, react, angular etc.) deep dives!
Posted on December 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.