3

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!

Jolly
  • 1,678
  • 2
  • 18
  • 37

1 Answers1

3

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

  • I'm gonna try the first proposal. Though, two things: (1) in the real example, I'm using `T extends MyCustomClass`. Still, even if not using `{}`, the error persists. Moreover, I DO need the `exnteds MyCustomClass` because inside `testMethod` I'm going to access properties that are defined in `MyCustomClass`. (2) I thought that the the `context` property in the second example is the discriminator property.. But, in one case is a value different from `null`, and in the other is `null` – Jolly Sep 30 '21 at 12:12
  • @Jolly please provide an example from your real code, I mean with error – captain-yossarian from Ukraine Sep 30 '21 at 12:13
  • 1
    i really like the last option using a `tag`. – windmaomao Sep 30 '21 at 12:25
  • @captain, the error is `a.context[a.name]` can't be figured out by the compiler what it is. – windmaomao Sep 30 '21 at 12:26
  • The problem with the `tag` is that i'm adding another discriminator property, when instead `context` should have that role. BTW, I've edited the first message. Also, `hasProperty` approac works – Jolly Sep 30 '21 at 12:33
  • @Jolly as far as I understood `validation` variable should be an array, but you are trying to assign an object to it. `validation = props.context.validation[props.name]`. Is this correct? – captain-yossarian from Ukraine Sep 30 '21 at 12:47
  • @windmaomao this is because `context` is expected to be `Record`. Please share reproducable example of the error you are getting – captain-yossarian from Ukraine Sep 30 '21 at 12:48