Issue with REACT component discriminated props

Viewed 100

I'm trying to develop a REACT component using TypeScript, and to be more precise, using discrimination union types.
What I want to achieve is that the props of my component can have the context prop, and, if it does, then the name props is of type keyof T. Otherwise, name is a simple string.

Here's the first example:

interface IBaseProps {
    label: string;
}

interface IWithContext<T> extends IBaseProps {
    context: T;
    name: keyof T;
}

interface IWithoutContext extends IBaseProps {
    name: string;
}

type IProps<T> = IWithContext<T> | IWithoutContext;

const TestComp = <T extends {}>(props: IProps<T>) => {
    const testMethod = () => {
        if (props.context) { // Property 'context' does not exist on type 'IProps<T>'. Property 'context' does not exist on type 'IWithoutContext'.
            console.log('Test_1');
        }
    }
    
    return <p>Hello World</p>
}

In this case, the issue is that the property context is not in IWithoutContext. Easy fix: add the context in that interface as well with null type.

interface IBaseProps {
    label: string;
}

interface IWithContext<T> extends IBaseProps {
    context: T;
    name: keyof T;
}

interface IWithoutContext extends IBaseProps {
    context: never;
    name: string;
}

type IProps<T> = IWithContext<T> | IWithoutContext;

const TestComp = <T extends {}>(props: IProps<T>) => {
    const testMethod = () => {
        if (props.context !== null) { // OK
            if (props.context[props.name]) { // Type 'string | keyof T' cannot be used to index type 'T'.
                console.log('Test_1');
            }
        }
    }
    
    return <p>Hello World</p>
}

But now, I've a different error: apparently, typescript is not able to understand that props.name is of type keyof T, based on the fact that props.context is different from null.

Though, I think that, due to the interfaces, it should be inferred that props.name is of type keyof T.

What am I missing here?


EDIT: here's a more comprehensive example.

interface IValidationRule {
    id: string;
}

interface IValidation {
    [key: string]: IValidationRule[];
}

class MyCustomClass {
    validation: IValidation;
}

interface IBaseProps {
    label: string;
}

interface IWithContext<T> extends IBaseProps {
    context: T;
    name: keyof T;
}

interface IWithoutContext extends IBaseProps {
    context: never;
    name: string;
    validation: IValidation;
}

type IProps<T> = IWithContext<T> | IWithoutContext;

const TestComp = <T extends MyCustomClass>(props: IProps<T>) => {
    const testMethod = () => {
        let validation : IValidation[] = null;

        /* In case context is defined, I can use 'name' to retrieve the validation. */
        if (props.context !== null) {
            validation = props.context.validation[props.name];
        }
        /* Otherwise, if context is null, validation shuold be provided in the props. */
        else {
            validation = props.validation;
        }
    }

    return <p>Hello World</p>
}

Apparently, the problem is that type discrimination does get along with generic type T or null value.

By the way, the real example is really like this.. Just, the class has more properties.

Also, I've tried the hasProperty approach, and it works great!

1 Answers

You have two options to handle it.

First one, make user defined type guard

import React from 'react';

interface IBaseProps {
    label: string;
}

interface IWithContext<T> extends IBaseProps {
    context: T;
    name: keyof T;
}

interface IWithoutContext extends IBaseProps {
    name: string;
}

type IProps<T> = IWithContext<T> | IWithoutContext;

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

const TestComp = <T extends Record<string, unknown>>(props: IProps<T>) => {
    const testMethod = () => {
        if (hasProperty(props, 'context')) {
            console.log('Test_1');
            props.context // Record<string, unknown>
        }
    }

    return <p>Hello World</p>
}

Playground

hasProperty - is a custom type guard.

Second one, you can relax your union:

import React from 'react';

interface IBaseProps {
    label: string;
}

interface IWithContext<T> extends IBaseProps {
    context: T;
    name: keyof T;
}

interface IWithoutContext extends IBaseProps {
    name: string;
}

type UnionKeys<T> = T extends T ? keyof T : never;

type StrictUnionHelper<T, TAll> =
    T extends any
    ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;
// credit goes to https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type StrictUnion<T> = StrictUnionHelper<T, T>

type IProps<T> = StrictUnion<IWithContext<T> | IWithoutContext>;

const TestComp = <T extends Record<string, unknown>>(props: IProps<T>) => {
    const testMethod = () => {
        if (props.context) {
            props.context // Record<string, unknown>
        }
    }

    return <p>Hello World</p>
}

Playground

Please see this answer for more explanation of StrictUnion

More information about using discriminated union with react components you can find in my blog here and here

SUMMARY

Using T extends {} is considered a bad practice. Type of {} is tooo wide. Consider using Record<string, unknown> instead.

IProps is not a discriminated union.

interface IWithContext<T> extends IBaseProps {
    context: T;
    name: keyof T;
}

interface IWithoutContext extends IBaseProps {
    name: string;
}

type IProps<T> = IWithContext<T> | IWithoutContext;

To make it a discriminated union, you should provide a special property witch will be required in each union and will be different. For example:

import React from 'react';

interface IBaseProps {
    label: string;
}

interface IWithContext<T> extends IBaseProps {
    tag: 'with'
    context: T;
    name: keyof T;
}

interface IWithoutContext extends IBaseProps {
    tag: 'without'
    name: string;
}


type IProps<T> = IWithContext<T> | IWithoutContext;

const TestComp = <T extends Record<string, unknown>>(props: IProps<T>) => {
    const testMethod = () => {
        if (props.tag === 'with') {
            props.context // Record<string, unknown>
        }
    }

    return <p>Hello World</p>
}

Playground

Please see example of discriminated union from the docs

Related