Search code examples
typescripttype-safetyunion-typestype-narrowing

TypeScript not catching potential undefined properties in union type with Record<string, never>


I'm encountering what seems to be unexpected behavior in TypeScript when working with a union type that includes Record<string, never> (empty object). TypeScript isn't catching potential undefined properties access, which could lead to runtime errors.

Here's a minimal example:

type CatData = {
    name: string;
    breed: string;
    age: number;
};

type MaybeCatData = Record<string, never> | CatData;

// TypeScript doesn't complain about this, but it should!
function processCat(obj: MaybeCatData): CatData {
    return {
        name: obj.name,
        breed: obj.breed,
        age: obj.age
    };
}

/**
 * Returns: {
 * "name": undefined,
 * "breed": undefined,
 * "age": undefined
 * } 
 */
console.log(processCat({}));

The Problem

  1. When I define a union type MaybeCatData = Record<string, never> | CatData, I expect TypeScript to force me to check whether the properties exist before accessing them, since one possibility is an empty object.

  2. However, TypeScript allows direct access to these properties without any type checking, even though accessing properties on an empty object will return undefined.

Question

Is there a better way to type this scenario to force proper type checking?


Solution

  • In your use case (receiving an Express response with two possibilities: an empty body or a cat in its body), you could create this type:

    type MaybeCatData = {} | CatData;
    

    This type {} is problematic in case you're using it to create variables, However, in your case, this isn't an issue because you won't create a MaybeCatData value yourself, you'll only receive it in a function.

    With this type and a type predicate, you can have your safe function, that prevents you from accessing the CatData properties without first checking the type:

    type CatData = {
        name: string;
        breed: string;
        age: number;
    };
    
    type MaybeCatData = {} | CatData;
    
    function isCatData(obj: MaybeCatData): obj is CatData {
        const keyName: keyof CatData = "name";
        return keyName in obj;
    }
    
    function processCat(obj: MaybeCatData): CatData {
        if (!isCatData(obj)) {
            throw new Error("Empty body");
        }
    
        return {
            name: obj.name,
            breed: obj.breed,
            age: obj.age
        };
    }
    

    Playground link