Control flow analysis of aliased conditional expressions in TypeScript
Will Heslam
Posted on July 5, 2021
An interesting feature was added to TypeScript recently that will improve the ergonomics of code that relies on type narrowing or discrimination:
TS 4.4 can infer when a variable's type implies something about the type of another.
A simple example given in the PR description:
function fn(x: unknown) {
const isString = typeof x === 'string'
if (isString) {
x.length // Ok
}
}
In TS 4.3.4, accessing x.length
is a type error...
Even though we know that if (and only if) isString
is true
, x
must be a string
, the type checker doesn't know that!
This is because isString
is just a stupid old boolean - it doesn't know or care why it happens to be true or false.
For TS to understand the expression implies something about its inputs, typeof x === 'string'
has to be inlined inside the if statement (or ternary if you're that way inclined).
function fn(x: unknown) {
if (typeof x === 'string') {
x.length // Ok
}
}
This is pretty annoying because we can no longer rearrange our code as we see fit.
We have to choose: do we structure our program to appease the cold, emotionless type checker, or appease nice and cuddly humans using lovely abstractions like names and expression reuse?
We can have our cake and eat it by pulling out the expression into a type guard predicate, but that's a lot of boilerplate and even bug prone - if our guard predicate and function body fall out of sync, we have an invisible type-checker-defeating bug on our hands!
function brokenIsStr(x: unknown): x is string {
return typeof x !== 'string'
}
That's a very dense and dry cake!
At this point TS is looking less like "just JS with types" and more like a verbose subset that's hard to read and write.
This has changed in TS 4.4, as isString
is now imbued with the implication our brains associate with it - TS understands that iff isString
is true
, x
must be a string
.
This means we can start decoupling our conditionals from the expressions they depend on; our TS programs start looking a bit more nimble, our cake a little moister!
Limitations
Variables don't encode a history of their every logical implication - it's not magic.
foo
's type can only imply something about bar
when foo
is const
and either:
- the result of a conditional expression about
bar
in the current scope (i.e.foo
is a boolean) - a discriminant property of
bar
(i.e.bar
is a discriminated union)
It supports up to 5 levels of indirection before giving up:
function fn(x: unknown) {
const isString = typeof x === 'string'
const twoLevelsDeep = isString || isString
const threeLevelsDeep = twoLevelsDeep || isString
const fourLevelsDeep = threeLevelsDeep || isString
const fiveLevelsDeep = fourLevelsDeep || isString
const sixLevelsDeep = fiveLevelsDeep || isString
const justOneLevelDeep = isString || isString || isString || isString || isString || isString
if(fiveLevelsDeep) {
x // string
}
if(sixLevelsDeep) {
x // unknown
}
if(justOneLevelDeep) {
x // string
}
}
and as of yet it doesn't fold away identical expressions.
Whilst an aliased conditional expression on a destructured field will allow for narrowing the original object's type, the flow analysis cannot narrow the type of a destructured sibling.
This coincidentally makes destructuring arguments inside the function signature less useful to the type checker - you may be better off destructuring arguments on the next line.
As an example, a predicate upon foo
cannot influence the inferred type of bar
here:
function fn({ foo, bar }: Baz) {
...
But it can influence the type of baz
:
function fn(baz: Baz) {
const { foo, bar } = baz
...
This might change in the future, but it's something to bear in mind.
Another important limitation is that narrowing a specific property of an object (as opposed to narrowing the type of the object overall) requires that property to be readonly, potentially tipping the balance in favour of readonly properties by default.
Despite going out of its way to support mutability, the more advanced TypeScript's analysis gets, the more it encourages functional programming with immutability.
Downsides
There's inevitably some implicit complexity introduced - we'll have to take care to remember when a seemingly innocent boolean is being relied upon by the type checker elsewhere.
Any kind of inference increases coupling between disparate parts of our program - a change over here is more likely to change something over there.
This is a trade off we make all the time; to avoid it entirely requires redundantly and tediously enunciating every single type in your program.
Anyone stuck working with an older version of TS will also have to be slightly more careful when blindly copy pasting from the internet - the weaker inference may render copied code incompatible.
A Practical Example
Let's build a slightly contrived e-commerce website with React - how hard could it be?
Our customers will go through several steps - browsing the catalogue, selecting shipping, then confirming and paying for their order.
Let's represent those steps as React component state using a discriminated union... something like:
type ShoppingStep = {
step: "shopping"
discountCode?: string
loggedIn: boolean
}
type SelectShippingStep = Omit<ShoppingStep, "step"> & {
step: "select-shipping"
items: Array<Item>
}
type ConfirmOrderStep = Omit<SelectShippingStep, "step"> & {
step: "confirm-order"
shippingAddress: Address
}
export function OnlineShop(): JSX.Element {
const [state, setState] = useState<
ShoppingStep | SelectShippingStep | ConfirmOrderStep
>({
step: "shopping",
loggedIn: false,
})
...
}
With each step represented as a separate component:
function Catalogue(props: ShoppingStep): JSX.Element
function ShippingSelect(props: SelectShippingStep): JSX.Element
function ConfirmOrder(
props: ConfirmOrderStep & {
freeShipping: boolean;
children?: ReactNode
},
): JSX.Element
Now let's put it all together by picking the component depending on the step and calculating free shipping eligibility:
const shippingMessage =
"shippingAddress" in state &&
checkFreeShippingEligibility(
state.items,
state.shippingAddress
)
? `Congrats! Free shipping on ${state.items.length} items!`
: undefined
switch (state.step) {
case "shopping":
return <Catalogue {...state} />
case "select-shipping":
return <ShippingSelect {...state} />
case "confirm-order":
return (
<ConfirmOrder
{...state}
freeShipping={
"shippingAddress" in state &&
checkFreeShippingEligibility(
state.items,
state.shippingAddress
)
}
>
{shippingMessage ?? "Now pay up!"}
</ConfirmOrder>
)
}
Here's the full code in the playground.
This works, but our shipping message logic is pretty dense, and our free shipping check is duplicated!
Can we do better?
Let's split apart the shipping message logic and reuse the free shipping check:
const freeShipping =
"shippingAddress" in state &&
checkFreeShippingEligibility(
state.items,
state.shippingAddress
)
const shippingMessage =
freeShipping
? `Congrats! Free shipping on ${state.items.length} items!`
: undefined
...
case "confirm-order":
return (
<ConfirmOrder {...state} freeShipping={freeShipping}>
{shippingMessage ?? "Now pay up!"}
</ConfirmOrder>
)
Much better! But this line:
? `Congrats! Free shipping on ${state.items.length} items!`
actually fails the type checker in TS 4.3.4 due to state.items
not necessarily being present: here's proof.
The fix is to duplicate the shipping address check:
const shippingMessage =
"shippingAddress" in state && freeShipping
? `Congrats! Free shipping on ${state.items.length} items!`
: undefined
and now we're paying the price just to satisfy the type checker.
Let's take advantage of the enhanced inference introduced in TS 4.4 to not only deduplicate, but further tidy up our code!
const hasShippingAddress = "shippingAddress" in state
// `hasShippingAddress` conditional alias
// allows state to be narrowed to ConfirmOrderStep
// so `items` and `shippingAddress` are known to be present
const freeShipping =
hasShippingAddress &&
checkFreeShippingEligibility(
state.items,
state.shippingAddress
)
// state is again narrowed to ConfirmOrderStep because
// `freeShipping` is an aliased conditional twice removed!
const shippingMessage = freeShipping
? `Congrats! Free shipping on ${state.items.length} items!`
: undefined
const {step} = state
// switching on an (aliased) destructured discriminant property
switch (step) {
...
case "confirm-order":
return (
<ConfirmOrder {...state} freeShipping={freeShipping}>
{shippingMessage ?? "Now pay up!"}
</ConfirmOrder>
)
}
Here's the full code in 4.4 as compared to the same in 4.3.4.
This is loads better - we've got (slightly more) destructuring, lots of named variables and naturally narrowed types, without duplicating type guard expressions.
Conclusion
TS 4.4's flow analysis of aliased conditional expressions starts to deliver - to stretch an analogy - a type checked, moist and light, more JavaScript-y cake.
Our TS code can start looking a bit more like the flexible, human-friendly programs we're used to; we're telling the machine what to do, not the other way around!
Included in the 4.4 release notes is another write-up of the new feature - I recommend giving the whole thing a read as there are a bunch of juicy new features waiting to be tried out!
Posted on July 5, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024
November 23, 2024
November 16, 2024