Andrei V
Andrei V

Reputation: 7506

Require at least one of two properties to be provided in props

Using TypeScript, it is possible to create a type in which at least one of two properties is required. There are (at least) two ways to go about it: using union types and a somewhat complexer generic solution. These two solutions work well. I'm able to create and get the desired compiler error when neither of the two properties are specified.

However, when using the final type to specify the props in a react component, the compiler is unable to find the two "required" properties. Here's a simplified example:

interface Base {
  text?: string;
}
interface WithId extends Base {
  id: number;
}
interface WithToken extends Base {
  token: string;
}
type Identifiable = WithId | WithToken | (WithId & WithToken);

//OK
const item: Identifiable = {
   text: "some text"
   id: 4
};
//OK
const item: Identifiable = {
   token: "some token"
};
//Error
const item: Identifiable = {
   text: "some text"
};

//OK
export class MyComponent extends React.Component<Identifiable, any> {
   render() {
      //Error (see below)
      return <p>{this.props.id}</p>
   }
}

The error received when trying to access one of the two required props (regardless where inside the class) looks something like this:

Property 'id' does not exist on type '(Readonly<{children?: ReactNode;}> & Readonly<WithId>) | (Readonly<{children?: ReactNode;}> & Readonly<WithToken>) | (Readonly<{children?: ReactNode;}> & Readonly<WithId & WithToken>)'
Property 'id' does not exist on type '(Readonly<{children?: ReactNode;}> & Readonly<WithToken>'.

Is there a way to work around this and have the compiler understand these requirements?

Note: using TypeScript 3.0.1, React 16.4.2, with the latest typings available to date.

Upvotes: 3

Views: 3289

Answers (4)

Al Joslin
Al Joslin

Reputation: 783

I wrote an NPM module for this: https://www.npmjs.com/package/react-either-property

The code maintains the type checking options, offers EitherOptional and EitherRequired strategies -- it supports combining multiple usages in one props definition.

Note: the custom rule's property name is a throwaway, and its usage as an actual property is undefined.

import { EitherOptional, EitherRequired } from 'react-either-property';

    [ module code goes here] 

ComponentSeven.propTypes = {
  east: PropTypes.number,
  west: PropTypes.number,
  north: PropTypes.number,
  south: PropTypes.number,
  ignored: EitherOptional('north', 'south'),
  undefined: EitherRequired('north', 'south'),
};

Upvotes: 0

Andrei V
Andrei V

Reputation: 7506

The common idea presented in the other answers, that using type guards can satisfy the compiler, is correct and works well. However, inspired by how union types work and by the answers referenced in the question, I've decided to try and build a generic type that would work without requiring type guards (please note that, although type guards are not necessary for the TypeScript compiler, checking if a property exists on an object in JavaScript should still be done at run time, depending on your scenario).

This is the solution that finally worked for me:

interface Base {
   text?: string;
   id?: number;
   token?: string;
}

type RequireProperty<T, Prop extends keyof T> = T & {[key in Prop]-?:T[key]};
type RequireIdOrToken = RequireProperty<Base, "id"> | RequireProperty<Base, "token">;

//Error
const o1: RequireIdOrToken = { };
//Error
const o2: RequireIdOrToken = { 
   text: "some text"
};
//OK
const o3: RequireIdOrToken = { 
   id: 4
};
//OK -> no type guard needed
o3.token = "a new token";
//OK
const o4: RequireIdOrToken = { 
   token: "some token"
};

The general idea is to have a union type between two types having the same properties. This avoids the need to use type guards. On each of the two types, one or the other property is marked as required. This requires both properties to be marked as optional in the "base" interface.

The example could be made generic

type RequireProperty<T, Prop extends keyof T> = T & {[key in Prop]-?:T[key]};
type RequireTwoProperties<T, Prop1 extends keyof T, Prop2 extends keyof T> 
         = RequireProperty<T, Prop1> |  RequireProperty<T, Prop2>;

and so extended to accept multiple properties. A generic type accepting a "list" of property names (subset of keyof T) and transforming it into a union type of RequireProperty type for each specified property, would be the perfect solution. I don't know if that is currently possible.

Upvotes: 3

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249706

You can't access fields of a union that are not common to all members of the union.

The options that come to mind are type-guards or using a different type for props when you actually use them.

The simple option is a type-guard, just check for the existence of the property using an in type-guard:

export class MyComponent extends React.Component<Identifiable, any> {
    render() {
        if ('id' in this.props) {
            // Ok
            return <p>{this.props.id}</p>
        }
    }
}

The second more complicated solution is to create a type from Identifiable that is the same union, but with each member of the union augmented with the missing fields form all the union with the missing fields being optional and of type undefined. Basically we want to get form { id: string } | { token: string } to { id: string; token?:undefined } | { token: string; id?:undefined }. This would allow us to access any field of the union while still being type safe under strictNullChecks.

This new type will still be compatible with your original props, and thus we would only need a local variable of this type and assign it with the class props.

type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never


type FlattenUnionHelper<T, TAll> = T extends any ? T & Partial<Record<Exclude<keyof TAll, keyof T>, undefined>> : never;
type FlattenUnion<T> = FlattenUnionHelper<T, UnionToIntersection<T>>


export class MyComponent extends React.Component<Identifiable, any> {
    render() {
        let props: FlattenUnion<Identifiable> = this.props
        return <p>{props.id}</p>
    }
}

I think you can use the flattened version directly as props but you should test all your requirements are met, from what I tested it works fine

export class MyComponent extends React.Component<FlattenUnion<Identifiable>, any> { /*...*/}
let f = <MyComponent id={0} />
let f2 = <MyComponent token="" />
let f3 = <MyComponent id={0} token="" />
let f4 = <MyComponent text="" /> // error

Upvotes: 4

Laurence Dougal Myers
Laurence Dougal Myers

Reputation: 1072

Your "Identifiable" props can be the type "WithToken", which doesn't have a property "id". The compiler is correctly flagging your current usage as an error.

Get around this by using a "type guard" and wrapping your rendering code in an if statement.

function isWithId(value: Identifiable): value is WithId {
   return (value as WithId).id != undefined;
}

export class MyComponent extends React.Component<Identifiable, any> {
   render() {
      if (isWithId(this.props)) {
         return <p>{this.props.id}</p>;
      } else {
         return null;
      }
   }
}

Upvotes: 2

Related Questions