Home > Blockchain >  How can I enforce different typescript interface classes based on a type condition in React?
How can I enforce different typescript interface classes based on a type condition in React?

Time:10-23

I am trying to enforce certain properties of a component depending on the type of the component... Basically I have a main interface with heroStyling types:

export type HeroStyling = 'SimpleForm' | 'WithTickerHeader' | 'WithTagLineHeader' | 'WithRating'

export interface AdaptiveHeroMainProps {
  heroStyling: HeroStyling
  cta: string
  ctaUrl: string
  image: FluidObject
  imageAlt: string
}

and I am extending this to four other classes depending on the type of the heroStyling key:

export interface AdaptiveHeroMainProps {
  heroStyling: HeroStyling
  cta: string
  ctaUrl: string
  image: FluidObject
  imageAlt: string
}

export interface WithTagLineHeader extends AdaptiveHeroMainProps {
  headingTagLine: string
  heading: string
  leadParagraph: string
  bulletPoints?: string
}
export interface WithTickerHeader extends AdaptiveHeroMainProps {
  textCenter: boolean
  leadParagraph?: string
  bulletPoints?: string
}

export interface SimpleForm extends AdaptiveHeroMainProps {
  textCenter: boolean
  heading: string
  leadParagraph?: string
  bulletPoints?: string
}
export interface WithRating extends AdaptiveHeroMainProps {
  textCenter: true
  reviewStars?: boolean
}

My question is how can I enforce an extended class properties depending on what the type heroStyling is on my component without TypeScript complaining about one property not existing on a type? I need to deconstruct all props as I render according to their truthy or falsey values.

 const CampaignHero = ({ 
    heroStyling, textCenter, reviewStars, headingTagLine, 
    heading, leadParagraph, bulletPoints, cta, ctaUrl, image, imageAlt }
    : WithTagLineHeader | WithTickerHeader | SimpleForm | WithRating ): React.ReactElement => {
return (
    <section>
          {headingTagLine && (
            <div      
              dangerouslySetInnerHTML={{ __html: headingTagLine }}
            />
          )}
          {heading && <h1>{heading}</h1>}
          {leadParagraph && <p>{leadParagraph}</p>}
          {bulletPoints && (
            <div
              dangerouslySetInnerHTML={{ __html: bulletPoints }}
            />
          )}
            <Button href={ctaUrl}>{cta}</Button>
          {reviewStars && (
              <HeroRating />
          )}
        </div>
          <Img alt={imageAlt} fluid={image} loading="eager" />
    </section>
  )
}

Question after captain-yossarian great help: If I render the component as below I get no errors but I should since bulletPoints is not part of the WithRating interface. TS complains only when one of the defined interface properties is missing, not when there is one that shouldn't exist.

<AdaptiveHero
        heroStyling={'WithRating'}
        reviewStars={true}
        textCenter={true}
        cta={hero.cta}
        ctaUrl={hero.ctaUrl}
        image={hero.image.fluid}
        imageAlt={hero.image.alt}
        bulletPoints={hero.bulletPoints}
      />

CodePudding user response:

If you have a union, you are allowed to use only common properties which are shared across each union type. You are not allowed to use headingTagLine without narrowing. Please see the docs:

Sometimes you’ll have a union where all the members have something in common. For example, both arrays and strings have a slice method. If every member in a union has a property in common, you can use that property without narrowing: This is the only way to preserve type safety.

In order to narrow the union type, you should create custom typeguard, which has impact on runtime.

However, it is possible to handle it without affection runtime. COnsider this example:

import React, { FC } from 'react'

type FluidObject = {
  tag: 'FluidObject'
}

export type HeroStyling = 'SimpleForm' | 'WithTickerHeader' | 'WithTagLineHeader' | 'WithRating'

export interface AdaptiveHeroMainProps {
  heroStyling: HeroStyling
  cta: string
  ctaUrl: string
  image: FluidObject
  imageAlt: string
}

export interface AdaptiveHeroMainProps {
  heroStyling: HeroStyling
  cta: string
  ctaUrl: string
  image: FluidObject
  imageAlt: string
}

export interface WithTagLineHeader extends AdaptiveHeroMainProps {
  headingTagLine: string
  heading: string
  leadParagraph: string
  bulletPoints?: string
}
export interface WithTickerHeader extends AdaptiveHeroMainProps {
  textCenter: boolean
  leadParagraph?: string
  bulletPoints?: string
}

export interface SimpleForm extends AdaptiveHeroMainProps {
  textCenter: boolean
  heading: string
  leadParagraph?: string
  bulletPoints?: 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;

// credits goes to https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type StrictUnion<T> = StrictUnionHelper<T, T>

const Button: FC<{ href: string }> = ({ children }) => <span> children</span>

const CampaignHero = ({
  heroStyling, textCenter, reviewStars, headingTagLine,
  heading, leadParagraph, bulletPoints, cta, ctaUrl, image, imageAlt }
  : StrictUnion<WithTagLineHeader | WithTickerHeader | SimpleForm>): React.ReactElement => {
  return (
    <section>
      {headingTagLine && (
        <div
          dangerouslySetInnerHTML={{ __html: headingTagLine }}
        />
      )}
      {heading && <h1>{heading}</h1>}
      {leadParagraph && <p>{leadParagraph}</p>}
      {bulletPoints && (
        <div
          dangerouslySetInnerHTML={{ __html: bulletPoints }}
        />
      )}
      <Button href={ctaUrl}>{cta}</Button>
      {reviewStars && (
        <HeroRating />
      )}
      <Img alt={imageAlt} fluid={image} loading="eager" />
    </section >
  )
}

Playground

Please read this question/answer in order to understand whats going on.

UPDATE

You should be aware of that your union is not discriminated. In order to do that, you should add unique property to each type:

export interface AdaptiveHeroMainProps {
  heroStyling: HeroStyling
  cta: string
  ctaUrl: string
  image: FluidObject
  imageAlt: string
}

export interface WithTagLineHeader extends AdaptiveHeroMainProps {
  heroStyling: 'WithTagLineHeader' // unique
  headingTagLine: string
  heading: string
  leadParagraph: string
  bulletPoints?: string
}
export interface WithTickerHeader extends AdaptiveHeroMainProps {
  heroStyling: 'WithTickerHeader' // unique
  textCenter: boolean
  leadParagraph?: string
  bulletPoints?: string
}

export interface SimpleForm extends AdaptiveHeroMainProps {
  heroStyling: 'SimpleForm' // unique
  textCenter: boolean
  heading: string
  leadParagraph?: string
  bulletPoints?: string
}
export interface WithRating extends AdaptiveHeroMainProps {
  heroStyling: 'WithRating' // unique
  textCenter: true
  reviewStars?: boolean
}

Full code:

import React from 'react'
export type HeroStyling = 'SimpleForm' | 'WithTickerHeader' | 'WithTagLineHeader' | 'WithRating'

type FluidObject = {
  tag: 'FluidObject'
}
export interface AdaptiveHeroMainProps {
  heroStyling: HeroStyling
  cta: string
  ctaUrl: string
  image: FluidObject
  imageAlt: string
}

export interface WithTagLineHeader extends AdaptiveHeroMainProps {
  heroStyling: 'WithTagLineHeader'
  headingTagLine: string
  heading: string
  leadParagraph: string
  bulletPoints?: string
}
export interface WithTickerHeader extends AdaptiveHeroMainProps {
  heroStyling: 'WithTickerHeader'
  textCenter: boolean
  leadParagraph?: string
  bulletPoints?: string
}

export interface SimpleForm extends AdaptiveHeroMainProps {
  heroStyling: 'SimpleForm'
  textCenter: boolean
  heading: string
  leadParagraph?: string
  bulletPoints?: string
}
export interface WithRating extends AdaptiveHeroMainProps {
  heroStyling: 'WithRating'
  textCenter: true
  reviewStars?: boolean
}

const CampaignHero = (props: WithTagLineHeader | WithTickerHeader | SimpleForm | WithRating): React.ReactElement => {
  if(props.heroStyling==='WithRating'){
    props.textCenter  // true
  }
  if(props.heroStyling==='WithTickerHeader'){
    props.textCenter // boolean
  }
  // etc ...
  return (
    <section>

    </section>
  )
}

<CampaignHero
  heroStyling={'WithRating'}
  reviewStars={true}
  textCenter={true}
  cta={hero.cta}
  ctaUrl={hero.ctaUrl}
  image={hero.image.fluid}
  imageAlt={hero.image.alt}
  bulletPoints={hero.bulletPoints} // expected error
/>

Playground

  • Related