typescriptstrictstrictnullchecks

TypeScript Argument Error with strictNullChecks when Checking Nested Object Property


I am facing an issue with TypeScript's strictNullChecks setting. I have a function handleAction which expects an argument of type MyType.

type MyType = {
  prop: MyEnum;
  // ... other properties
};

enum MyEnum {
  Value1,
  Value2,
  // ... other values
}

async function handleAction(item: MyType) {
  // Function logic here
}

Now, I have an object myRecord which has a property nestedItem that can be null.

type MyRecord = {
  nestedItem: MyType | null;
  // ... other properties
};

const myRecord: MyRecord = {
  nestedItem: null, // or some MyType object
  // ... other properties
};

I want to check if nestedItem exists and if its prop is Value1. I tried two approaches:

Approach 1:

const isCertainCondition =
  myRecord.nestedItem &&
  myRecord.nestedItem.prop === MyEnum.Value1;
  
if (isCertainCondition) {
  await handleAction(myRecord.nestedItem);  // Error here
}

Approach 2:

if (
  myRecord.nestedItem &&
  myRecord.nestedItem.prop === MyEnum.Value1
) {
  await handleAction(myRecord.nestedItem);  // No error here
}

The first approach gives me a TypeScript error:

Argument of type 'MyType | null' is not assignable to parameter of type 'MyType'.
  Type 'null' is not assignable to type 'MyType'

The second approach works without any error.

Why is TypeScript behaving differently between these two approaches?


Solution

  • The reason why you get different results here is Narrowing | TypeScript Handbook

    When you have this type of an example, you use "Truthiness narrowing" to exclude null:

    async function example2() {
      // 2. this works
      if (myRecord.nestedItem) {
        myRecord.nestedItem
        //       ^? MyType
        await handleAction(myRecord.nestedItem);
        //                          ^? MyType
      }
    }
    

    Same happens here but you keep the narrowed information in the variable nestedItem:

    async function example3() {
      const nestedItem = myRecord.nestedItem;
    
      // 3. this works
      if (nestedItem) {
        nestedItem
        // ^? MyType
        await handleAction(nestedItem);
        //                 ^? MyType
      }
    }
    

    When you keep the check in the variable nestedItem and try to access myRecord.nestedItem, you lose the narrowed information because it's only located in the variable nestedItem:

    async function example4() {
      const nestedItem = myRecord.nestedItem;
    
      // 4. this does NOT work now because nestedItem and myRecord.nestedItem disconnected from each other
      if (nestedItem) {
        nestedItem
        // ^? MyType
        await handleAction(myRecord.nestedItem);
        //                          ^? MyType | null
      }
    }
    

    Although you can mitigate this by creating type predicate, e.g. isDefined:

    const isDefined = Boolean as unknown as <Type>(value: Type) => value is NonNullable<Type>;
    

    So instead of truthiness check you can use type predicate:

    async function example5() {
      // 5. nestedItem is narrowed down by type predicate
      if (isDefined(myRecord.nestedItem)) {
        await handleAction(myRecord.nestedItem);
        //                          ^? MyType
      }
    }
    

    Let me know if you have more questions

    TypeScript playground with all examples step by step - https://tsplay.dev/m0V4rw