neiya
neiya

Reputation: 3142

Why is a type guard failing type-check

In a React project I am using a type guard to ensure I am passing down a prop that has the appropriate type. This seems to work because TypeScript is not raising any error in my IDE. But when running type-check as a command, the compiler fails on that prop.

I have a component that is rendering a child component. That child component needs to receive a prop with a custom type, declared in an enum AuthorizedType. To ensure that the prop passed is valid, I created a type guard isAuthorizedType. If the type is not valid, the component won't render.

Because the child component is not rendered when the type guard is failing, TypeScript is not raising any error in my IDE. But when running type-check as a command, the compiler raises the error:

TS2322: Type 'string' is not assignable to type 'AuthorizedType'.

I wonder why is the error not raised in the IDE but raised when running type-checking. I understand that type guards perform a runtime check, and I wonder what it means exactly and if that's related.

Here is the code:

// ParentComponent.tsx

export enum AuthorizedType {
  First = 'first',
  Second = 'second',
}

function isAuthorizedType(value: any): value is AuthorizedType {
  return Object.values(AuthorizedType).indexOf(value) !== -1;
}

function ParentComponent({value}: {value: string}) {
  const isValidType = isAuthorizedType(value);

  if (!isValidType) {
    return null;
  }

  return (<ChildComponent value={value} />)
}

// ChildComponent.tsx
import {AuthorizedType} from '..';

function ChildComponent({value}: {value: AuthorizedType}) {
  switch(value) {
    case AuthorizedType.First:
      return <div>first</div>
    case AuthorizedType.Second:
      return <div>second</div>
  }
}

By moving the type guard into the child component, type-check is not raising any error anymore:

// ChildComponent.tsx
import {AuthorizedType} from '..';

function isAuthorizedType(value: any): value is AuthorizedType {
  return Object.values(AuthorizedType).indexOf(value) !== -1;
}

function ChildComponent({value}: {value: string}) {
  const isValidType = isAuthorizedType(value);

  if (!isValidType) {
    return null;
  }

  switch(value) {
    case AuthorizedType.First:
      return <div>first</div>
    case AuthorizedType.Second:
      return <div>second</div>
  }
}

I would like to understand why.

Upvotes: 1

Views: 1312

Answers (2)

Emma
Emma

Reputation: 855

After looking through your edits I thought it best to write up another answer entirely. Your program logic doesn't seem flawed to me and after verifying it, so I believe you've found a bug in TypeScript's control flow analysis.

Here's a playground link that works as expected on TS versions equal or below 4.3.5 but not as expected on versions equal or above 4.4.4. On the buggy version (>=4.4.4), the compiler reckons the return type of ParentComponent to be null, but actually calling the emitted ParentComponent() function returns "first".

I might've of course missed a configuration flag or something that affects how CFA works, or there might've been an intentional change in version 4.4.x, so check if you can find a related bug in GitHub. If not, I think you should file a report about this.

Upvotes: 1

Emma
Emma

Reputation: 855

Your ChildComponent is marked to expect an AuthorizedType as props. However in ParentComponent you're calling the child with { prop: AuthorizedType } as props. AuthorizedType extends string and that cannot overlap with the type { prop: AuthorizedType } and thus you get a TS2322 error.

The reason it goes away in your situation is when you move the isAuthorizedType call into your child component, it's trying to check if the props value it receives (i.e. { prop: AuthorizedType }) is an AuthorizedType (which it cannot be, since object does not extend string), assigns false to isValidType, and renders null.

So the problem is caused by expecting AuthorizedType as props in ChildComponent when actually you should expect { prop: AuthorizedType }. Of course, you'll probably want to name the property with something more descriptive like "value":

function Child(props: { value: AuthorizedType }) {
    switch(prop.value) {
        case AuthorizedType.First:
            return <div>first</div>
        case AuthorizedType.Second:
            return <div>second</div>
    }
}

The following format (using object destructuring) is equivalent to the above one but might prove more ergonomic:

interface ChildProps {
    value: AuthorizedType
}
function Child({ value }: ChildProps) {
    switch(value) {
        case AuthorizedType.First:
            return <div>first</div>
        case AuthorizedType.Second:
            return <div>second</div>
    }
}

You'll then call the Child with e.g. (see jsx ... spread attributes):

function ParentComponent(props: ChildProps) {
    return isAuthorizedType(props.value)
        ? <Child {...props} />
        : null;
}

Upvotes: 1

Related Questions