DEV Community

Will Heslam
Will Heslam

Posted on • Updated on

Control flow analysis of aliased conditional expressions in TypeScript

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. the result of a conditional expression about bar in the current scope (i.e. foo is a boolean)
  2. 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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) {
  ...
Enter fullscreen mode Exit fullscreen mode

But it can influence the type of baz:

function fn(baz: Baz) {
  const { foo, bar } = baz
  ...
Enter fullscreen mode Exit fullscreen mode

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

  ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
      )
  }
Enter fullscreen mode Exit fullscreen mode

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>
      )
Enter fullscreen mode Exit fullscreen mode

Much better! But this line:

      ? `Congrats! Free shipping on ${state.items.length} items!`
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
      )
  }
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (3)

Collapse
anthonyjoeseph profile image
Anthony G

Great article, thanks for writing it! I had a quick question:

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.

I'm not sure I understand this point. What do type predicates on foo do to the inferred type of bar in these cases?

function fn({ foo, bar }: Baz) { ... }
// vs
function fn(baz: Baz) {
  const { foo, bar } = baz
  ...
Enter fullscreen mode Exit fullscreen mode

Here's a playground of what I thought was supposed to happen but didn't

Collapse
willheslam profile image
Will Heslam Author

Thanks! :)

I definitely under explained this point... currently no type predicates on foo can act on the inferred type of bar.
In your example, bar will always be number | boolean, regardless.

However, type predicates on foo can influence the inferred type of baz, provided it's available in scope. Hence the differentiation between destructuring in the function signature rather than in the body: in the former there's no baz to influence.

I'm glad you raised this point because I realised I didn't know exactly how type narrowing works for object unions!

Your playground is a good example - I'd intuitively assume that the type of foo or bar could be used to differentiate which part of the union baz happens to be.
That actually doesn't work, which surprised me.

If you ignore type predicate/guard functions, it turns out TS has just two ways* to narrow unions of objects: in operator narrowing and discriminated unions.

So you either have to add an extra property to one of the unions, e.g.

if('qux' in baz){
  ...
Enter fullscreen mode Exit fullscreen mode

which defeats the point of destructuring, or by changing one of the fields to be a discriminant.

Discriminants must be a common property of the union where each type is a literal.
Here's a version of your playground where foo is now a discriminant, and baz is now influenced by a condition on foo.

There's three types of literal types: strings, numbers and booleans.

I wonder if it'd be useful if there was a prefix keyword to indicate which fields of an object should be discriminants, similar to readonly.
That way TS could indicate if you've accidentally broken the contract during refactoring - maybe it's overkill though.

Hope that answers your question, and thanks for raising it - I'll be more careful when creating object unions in the future. :)

* It wouldn't surprise me if there were some extra undocumented ways to narrow object unions!

Collapse
anthonyjoeseph profile image
Anthony G

Ah amazing! Your playground explains it really well. I guess my issue wasn't really related to your article 😅 - I hadn't realized that unions can only discriminate on literal types. Amazing that baz can be narrowed by foo - powerful stuff!