Language-tools: TypeScript incorrectly considers variable as possibly null inside condition that checks variable

Created on 18 Oct 2020  ·  20Comments  ·  Source: sveltejs/language-tools

Describe the bug
TypeScript error "Object is possibly null" is shown for nested conditional checks.

To Reproduce
Simple test case:

<script lang="typescript">
    interface TestObject {
        author?: string;
    }
    export let test: TestObject | null;
</script>

{#if test}
    <div>
        {#if test.author}
            <div>Author: {test.author}</div>
        {/if}
    </div>
{/if}

TypeScript throws error Object is possibly null on line {#if test.author}, not specifically on test inside that line.

Expected behavior
Expected not to throw any errors.

System (please complete the following information):

  • OS: OSX 10.15.7
  • IDE: VSCode
  • Plugin/Package: "Svelte for VSCode"

Additional context
If nested conditional statement is removed and in TestObject interface author? is replaced with author (to make it required), it would be logical for TypeScript to throw the same error for {test.author}, but it doesn't. So looks like error is triggered by nested conditional statement, without it TypeScript knows that test is not null.

Fixed bug

Most helpful comment

Small update/summary:

  • control flow is reset as soon as the code is inside one of these constructs: #await, #each, let:X. This is due to the way we have to transform the code. We haven't forgotten about this issue and are still looking for ways to solves this in a good way.
  • nested if-conditions now should work as expected as long as they are not interrupted by one of the things mentioned in the first bullet point

All 20 comments

Don't know what can we do to deal with it. svelte2tsx transform it into this:

() => (<>

{() => {if (test){<>
    <div>
        {() => {if (test.author){<>
            <div>Author: {test.author}</div>
        </>}}}
    </div>
</>}}}</>);

because it's wrapped in a function so that the control flow type analysis won't take into effect. And we can't remove the function or wrapped into a immediately-invoked function because the expression would re-run when it's dependency cahnged, we definitely needs to invalidate some of the control flow type.

Could we turn this into ternaries? If there's not else block we just use an empty string for the else case.
But the bigger problem might be that we are not allowed to use functions like that in any of the transformations - not sure if that's the case right now

Maybe allow ! syntax? {#if test!.author}. That's valid TypeScript syntax, but right now it cannot be used in template.

Right now you can't use typescript in markup. There's no way to preprocess markup or let compiler parse typescript syntax. So non-null assertion is not possible.
Haven't tried the viability buy maybe we could copy the upper-level condition and append it before the nested condition

 {() => {if (test && test.author){<>
            <div>Author: {test.author}</div>
        </>}}}

Temporary workaround:

<script lang="typescript">
    interface TestObject {
        author?: string;
    }
    export let test: TestObject | null;

    let requitedTest: Required<TestObject>;
    $: {
        requiredTest = test as unknown as Required<TestObject>;
    }
</script>

{#if test}
    <div>
        {#if requiredTest.author}
            <div>Author: {requiredTest.author}</div>
        {/if}
    </div>
{/if}

Copying variable, assigning different type and using it in nested condition. It is ugly and needs to be used carefully, but it does get rid of warnings.

A more pragmatic workaround for now would be

{#if test}
    <div>
        {#if test?.author}
            <div>Author: {test.author}</div>
        {/if}
    </div>
{/if}

It's quite annoying, especially when it's not just drilling inside the same object but making separate conditions:

{#if bigSwitch}
    <div>
        {#if smallSwitch}
            <MyComponent {bigSwitch} />
        {/if}
    </div>
{/if}

That won't work. TypeScript is not allowed in templates.

Seems to work here. test && test.author then.

if you mean optional chaining, that's a new javascript feature, not just a typescript feature. It's available in svelte after 3.24.0

Thanks! I always assumed it was TS feature similar to non-null statement foo!. That makes it much simpler.

Always learning something new :)

Haven't tried the viability buy maybe we could copy the upper-level condition and append it before the nested condition

 {() => {if (test && test.author){<>
          <div>Author: {test.author}</div>
      </>}}}

I did some tests in that direction and I think this will not work for all cases. It may silence errors for #if, but they will reappear as soon as you use an #each or #await block inside it. These errors will not appear if the variable is a const, because TS control flow then knows that the condition must still be true because the variable cannot be reassigned. So the best way to solve this would be to transform everything to const variables, or reassign all variables to temporary other variables for the template. That will bring its own problems though for hover info, rename, go-to-declaration etc. Maybe that could be worked around by using comma-expressions and transform {#if foo} to {#if (foo, generatedConstFoo)}. Bottom line: this will require quite some hackery.

Maybe the "append upper-level if-blocks"-thing is correct after all. It is not safe to assume that the conditions are still true inside each/await blocks. For example this will throw a runtime error (toUpperCase is not a function):

<script>
    let name = 'name';
    function setNameToNumber(){
        name = 1
        return ['a']
    }
</script>

{#if name}
    <h1>Hello {name.toUpperCase()}!</h1>
    {#each setNameToNumber() as item}
        item
    {/each}
{/if}

The same can be constructed for an await-block.

Small update/summary:

  • control flow is reset as soon as the code is inside one of these constructs: #await, #each, let:X. This is due to the way we have to transform the code. We haven't forgotten about this issue and are still looking for ways to solves this in a good way.
  • nested if-conditions now should work as expected as long as they are not interrupted by one of the things mentioned in the first bullet point

Right now you can't use typescript in markup. There's no way to preprocess markup or let compiler parse typescript syntax. So non-null assertion is not possible.
Haven't tried the viability buy maybe we could copy the upper-level condition and append it before the nested condition

 {() => {if (test && test.author){<>
          <div>Author: {test.author}</div>
      </>}}}

Now that #493 is fixed in a way that declares $store-variables, we can revisit this idea. My idea is to prepend all if-conditions like this (#each as an example):

In

{#if somecondition && anothercondition}
   {#each ..}
     ..
   {/each}
{/if}

Out

<>{__sveltets_each((true, items), (item) => (somecondition && anothercondition) && <>
    ...
</>)}</>

The tricky part is to get elseif/else and nesting logic right.

This should be fixed with VS Code version 104.6.3 / svelte-check version 1.2.4 . Please let me know if it works for you now.

I've found an issue - This works correctly inside templates now, but not in event listeners. I'm not sure if I should open a new issue for it, but here's a repro:

<script lang="ts">
  let value: string | null = null;

  function acceptsString(value: string) {
    console.log(value);
  }
</script>

{#if value}
  <!-- Argument of type 'string | null' is not assignable to parameter of type 'string'.
  Type 'null' is not assignable to type 'string'. -->
  <button on:click={() => acceptsString(value)} />
{/if}

I've found an issue - This works correctly inside templates now, but not in event listeners. I'm not sure if I should open a new issue for it, but here's a repro:

<script lang="ts">
  let value: string | null = null;

  function acceptsString(value: string) {
    console.log(value);
  }
</script>

{#if value}
  <!-- Argument of type 'string | null' is not assignable to parameter of type 'string'.
  Type 'null' is not assignable to type 'string'. -->
  <button on:click={() => acceptsString(value)} />
{/if}

That's just the regular TS behavior though. Your value is referenced in a function that could be called any time in the future where value might be back to null.

Both are valid views. One could say "since value is not a const, it could change", but on the other hand one could also say "I never get to that click handler if value is null". Tricky ..

I'll open a different issue for that so we can discuss this seperately.

I don't think it's worth your time to discuss TS's own, old choices :p
Wouldn't the button stick around with value===null if there is an out transition at their or their parent's level for instance?

Strictly speaking the control flow was correct before these relaxing changes because one could modify other variables within an each loop or an await.

<script lang="ts">
  let foo: string | null = 'bar';
  function getItems() {
     foo = null;
     return [''];
  }
</script>

{#if foo}
  {#each getItems() as item}
    {foo.toUpperCase()} <!-- boom -->
  {/each}
{/if}

.. but who does this? You can fabricate the same runtime errors in TS, too, btw. So it's more of a tradeoff in DX than "this is 100% correct".

But before we discuss this further in here, please take this discussion to #876 😄

Was this page helpful?
0 / 5 - 0 ratings