question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Function body is not checked against assertion signature

See original GitHub issue

TypeScript Version: 3.7.0-dev.20191002

Search Terms: assertion signatures, asserts, not checked, unchecked, unsound

Code

function assertIsString(x: number | string): asserts x is string {
}

function test(x: number | string) {
    assertIsString(x);
    x.toUpperCase();
}

test(4);

Expected behavior:

This incorrect empty implementation of assertIsString should not type check, because there are execution paths through assertIsString that don’t narrow the type of x to string as the type of assertIsString should require.

Actual behavior:

No TS errors. Compiled JS code fails at runtime: TypeError: x.toUpperCase is not a function.

Playground Link: Playground nightly isn’t new enough, but maybe it will be by the time you read this: link.

Related Issues:

Issue Analytics

  • State:open
  • Created 4 years ago
  • Reactions:9
  • Comments:5 (3 by maintainers)

github_iconTop GitHub Comments

4reactions
anderskcommented, Oct 2, 2019

FTR, the existing type guards feature suffers from the same soundness hole (which, as far as I can tell, isn’t called out at all in the documentation!):

function isString(x: number | string): x is string {
    return true;
}

but I’m filing this against the new feature in the hope that there’s still time to give this more thought before it becomes stable. I can file another report for type guards if desired.

Perhaps these unchecked features were justified with the rationale of allowing one to explain invariants that the checker wouldn’t derive by itself. But I would argue that they are also useful for encapsulating checks that the checker would derive if they weren’t encapsulated, and that these purposes should be separate: it should be possible to encapsulate checks without bypassing the type system.

If one does need to bypass the type system, one can already do it explicitly with casts. Bypassing the type system should be explicit.

const assertIsString: (x: number | string) => asserts x is string =
  ((x: number | string): void => {}) as
  (x: number | string) => asserts x is string;
0reactions
gnpricecommented, Oct 3, 2019

A month later, someone extends one of the types involved in a way that makes the assertion function incorrect.

Here’s a fully-worked example of how that happens. Inevitably it’s rather longer than the boiled-down example @andersk gave at the top. I hope it’s nevertheless helpful for priming one’s intuition for how a bug of exactly this kind happens in a real codebase.

The example starts from exactly the types the documentation uses as an example for type guards. I’ve just adapted the code slightly to use assertion functions instead.

First, the types:

interface Bird {
    fly();
    layEggs();
}

interface Fish {
    swim();
    layEggs();
}

type Pet = Bird | Fish

These are verbatim from the docs’ example, except I’ve added an alias for the union type that the example mentions several times.

Now an assertion function, which is a direct translation of the type guard in the docs’ example:

function assertIsFish(pet: Pet): asserts pet is Fish {
    if ((pet as Fish).swim === undefined) {
        throw new Error("not a fish")
    }
}

Then here’s an example of using the assertion function:

function f() {
    // (... a bunch of app logic ...)

    // Because (... some invariant ...), this should in fact be a Fish.
    const fish = nextPet();
    // Assert that just to be sure.
    assertIsFish(fish);

    // Great, definitely a Fish.  We'll go do some delicate logic that
    // relies on the assumption this is a Fish.
    // (...)
    fish.swim();
    // (...)
    fish.layEggs();
    // (...)
}

At this stage, the assertion function is 100% correct, and all this code is quite reasonable.


Now, someone comes along and adds another type of pet, extending the union:

interface Amoeba {
    swim(): void;
    divide(): void;
}

type Pet = Bird | Fish | Amoeba

This is a perfectly reasonable change. Naturally they go on to update all the code that needs to change to handle the new Amoeba case, and they lean on the type-checker to help them be sure they’ve covered all of it.

They don’t make any change to assertIsFish because they don’t notice it – it might be in a different file, or even a completely different project. And the type-checker doesn’t give a peep about it.

But, in fact, assertIsFish is now wrong! It’ll accept a pet that’s an Amoeba, as well as one that’s a Fish.

As a result, f can blow right past the assertIsFish call even though its fish isn’t a fish at all, but an Amoeba. It’ll run through the subsequent logic with that assumption violated, and e.g. blow up with a runtime type error at fish.layEggs().

Here’s fully stitched-together versions of this example on the playground: before; after.


Unlike the Amoeba author, the type-checker looks at the definition of assertIsFish. And it has all the information it needs to see that the assertion function is now wrong – it’s not fundamentally different from the type checking that it does everywhere else. So it should be able to spot this bug in a straightforward assertion function like this one.

OTOH if I do write an assertion function in a way the type-checker doesn’t understand, then as @andersk says, the bypass should be something explicit like a cast.

PS: One might notice in this example that things only actually go wrong once there’s also a bug somewhere to break the invariant that the fish in f will actually be a Fish. But that’s just the nature of assertions – they only do anything when there’s already a bug somewhere – so it doesn’t really mitigate this issue. And in fact an analogous example with a type guard instead (if (isFish(pet)) fish.layEggs();) will already go wrong once Amoeba is added, with no exogenous bug involved at all.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Overloads not typechecking body - typescript - Stack Overflow
No it does not check the body against overload declarations. As you've seen, it checks that implementation signature is compatible with all ...
Read more >
Assertion Functions or Assertion Guards - TypeScript ...
The reason why assertion functions are also known as assertion guards is because of their similarity to type guards. In our type guard...
Read more >
single sign on issue The Signature in the assertion is not valid
We have an issue where we are attempting to use SSO but it is erroring in Salesforce. The Certifcates have not expired.
Read more >
Assertion functions in TypeScript - LogRocket Blog
Assertion functions in TypeScript are a very expressive type of function whose signature states that a given condition is verified if the  ......
Read more >
narrowing types via type guards and assertion functions - 2ality
Checking the result of typeof and similar runtime operations are called type guards. Note that narrowing does not change the original type of ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found