3

I want to create a TypeScript type that involves a set of fixed keys, plus a set of alternate groups of keys, such as:

type Props = {
   id: string
} & (
{
  height: number, 
  width: number
} 
  | {color: string}
)

Now, in this case, it correctly enforces that each of the groups have all the properties of that group, or none of them, i.e.: I can't use height without also using width.

Now, if I want to also allow an alternative without any of the optional keys, the only option that I know actually gives the desired result is by using {} as an alternative.

But lint rule @typescript-eslint/ban-types disallows that, saying that depending on the use case I should either Record<string, unknown>, unknown or Record<string, never>.

But none of these really work for this case. If I use unknown or Record<string, never> or object TS doesn't allow any of the keys in the alternatives. If I use Record<string, unknown> it doesn't allow any key unless I fill one of the alternative key groups.

Is there another way of doing this that I'm missing, or should I ignore the lint rule to achieve this?

4
  • Note that due to the relaxed excess-property check on unions, the Props above will also allow { id: 'i', width: 0, color: 'c' }, and if you add the {}, it will even allow { id: 'i', height: 0 }: tsplay.dev/mLyQKW Commented Sep 30, 2022 at 22:00
  • Huh, that's weird. I don't have the code before me, but I'm positive that I'm able to reach the desired result with this syntax in our app. Could it be a TS version issue? Commented Sep 30, 2022 at 22:20
  • @Oblosys I just read a bit about excess type checking, and seems like there is a range of different rules for when it's allowed or not. Curiously, looks like the case you reproduced is not supposed to work, so I don't know the explanation. But for React Component props it does check. Commented Sep 30, 2022 at 22:34
  • There's not a lot of documentation about it, but it's been like this since v3.5, and before that you could even have an excess property with an incorrect type. Here you can see it applied to components: tsplay.dev/WJ5zvm It is by design though: release notes 3.5 Commented Sep 30, 2022 at 22:37

2 Answers 2

2

I find in React that overloads a better case for this.

You can create an overload for each pattern that you want to support.

import React from 'react'

type RequiredProps = { id: string }

type SizeProps = { height: number, width: number } 
type ColorProps = { color: string }
type AllOptionProps = SizeProps | ColorProps


function MyComponent(props: RequiredProps): JSX.Element
function MyComponent(props: RequiredProps & SizeProps): JSX.Element
function MyComponent(props: RequiredProps & ColorProps): JSX.Element

function MyComponent(props: RequiredProps & Partial<AllOptionProps>) {
    console.log(props.id)
    if ('width' in props) console.log(props.width)
    if ('color' in props) console.log(props.color)
    return <></>
}

const a = <MyComponent id='abc' />
const b = <MyComponent id='abc' width={50} height={100} />
const c = <MyComponent id='abc' color='red' />

const d = <MyComponent id='abc' width={50} /> // error

See Playground


A second approach is to create a union where all props are in all members, but are forced to undefined if you say you can have them.

import React from 'react'

type RequiredProps = { id: string }

type NoOptionsProps = {
    height?: undefined,
    width?: undefined,
    color?: undefined
}
type SizeProps = {
    height: number,
    width: number,
    color?: undefined
} 
type ColorProps = {
    height?: undefined,
    width?: undefined,
    color: string
}
type Props = RequiredProps & (NoOptionsProps | SizeProps | ColorProps)

function MyComponent(props: Props) {
    console.log(props.id)
    console.log(props.width)
    console.log(props.color)
    return <></>
}

const a = <MyComponent id='abc' />
const b = <MyComponent id='abc' width={50} height={100} />
const c = <MyComponent id='abc' color='red' />

const d = <MyComponent id='abc' width={50} /> // error

See Playground

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks for the suggestions, Alex. Though I personally find these options a bit too verbose. I just wanted to know if there's some more direct alternative, since the lint message seems to imply that there's no case that can't be handled by the other alternatives...
Yeah, it may imply that, but in my experience, it's not true. What you dont want is this, albeit contrived, situation tsplay.dev/w23dbN. But if that scenario is fine with you, then you can always just tell eslint to stuff it, or use Record<never, never> which is the same as {} but eslint doesn't complain and at least doesn't look like a mistake.
1

You can try intersection of FC:

import React, { FC } from 'react'

interface Base {
  id: string
}

interface WithRect extends Base {
  height: number,
  width: number
}

interface WithColor extends Base {
  color: string
}


type Result = FC<WithColor> & FC<WithRect> & FC<Base>

const App: Result = (props) => <p></p>

const jsx = <App id="hello" color="green" /> // ok
const jsx____ = <App id="hello" /> // ok
const jsx___ = <App id="hello" width={1} height={1} /> // ok


const jsx_ = <App id="hello" color="green" height={1} /> // expected error
const jsx__ = <App id="hello" height={1} /> // expected error

Playground

This behavior is similar to function overloading, I would even say 95% similar but not equal.

If you are interested in typing React component props, see my articles my blog and dev.to

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.