Natasha
Natasha

Reputation: 816

TypeScript typing issue (marginally related to (P)React)

[Edit: I've simplified my original question]

Let's assume I want to define UI components in exactly the following way (the following lines shall not be changed in any way - any solution that will change the following lines is frankly not a solution I am looking for ... for example just writing render({ name: 'World' }) is not an option ... neither is the non-null assertion operator ...neither using currying or a builder pattern or something like a withDefaultProps helper function ... etc. ... these are just workarounds (yet easily working) for the actual problem below):

// please do not change anything in this code snippet

type HelloWorldProps = {
  name?: string
}

export default component<HelloWorldProps>({
  displayName: 'HelloWorld',
  defaultProps: { name: 'World' },

  render(props) {
    // the next line shall NOT throw a compile error
    // that props.name might be undefined
    return `HELLO ${props.name.toUpperCase()}`

    // [Edit] Please ignore that the function returns a string
    // and not a virtual element or whatever - this is not important here.
    // My question is about a TypeScript-only problem,
    // not about a React problem.

    // [Edit] As it has been caused some misunderstanding:
    // The type of argument `props` in the render function shall
    // basically be the original component props type plus (&) all
    // properties that are given in `defaultProps` shall be required now.
    // Those optional props that have no default value shall still
    // be optional. If ComponentType is the original type of the component
    // properties and the type of the `defaultProps` is D then
    // the type of the first argument in the render function shall
    // be: ComponentProps & D

     // [Edit] As it seems not to be 100% clear what I am looking for:
     // The problem is mainly because the function "component" depends basically
     // on two types: One is the type of the component props the other is
     // is the type of the default props. AFAIK it's currently only possible in
     // TypeScript to infer either both of them or none of them (or use
     // default types for the type variables - which is not very useful here
     // as the defaults are {}). But I only want to declare ONE type
     // (HelloWorldProps).
     // All workarounds that I know of are either to explictly declare both
     // types or split the single function "component" into two or more
     // functions - then you do not have that problem any more,
     // but then you have to change the syntax and that is exactly
     // what I do NOT want to do (a fact that is the most important
     // part of the  whole question):

     // [this is not the solution I am looking for]
     // export default component<HelloWorldProps>('HelloWorld')({
     //   defaultProps: {...},
     //   render(props) {...}
     // })

     // [this is not the solution I am looking for]
     // export default component<HelloWorldProps>('HelloWorld')
     //   .defaultProps({...})
     //   .render(props => ...) // `render` is the function component
     //                         // creator is this builder pattern

     // [this is not the solution I am looking for]
     // export default component<HelloWorldProps>({
     //   displayName: 'HelloWorld',
     //   render: withDefaultProps(defaultProps, props => { ... })
     // })

     // [this is not the solution I am looking for]
     // type HelloWorldProps = {...}
     // const defaultProps: Partial<HelloWorldProps> = {...}
     // export default component<HelloWorldProps, typeof defaultProps>({...})

     // [this is not the solution I am looking for]
     // return `HELLO ${props.name!.toUpperCase()}`

     // [this is not the solution I am looking for]
     // render(props: HelloWorldProps & typeof defaultProps) {...}   

     // [this is not the solution I am looking for]
     // render({ name = 'HelloWorld' }) {...}
  }
})

How exactly do I have to type the function component and the type ComponentConfig to make the above code work properly?

function component<...>(config: ComponentConfig<...>): any {
  ...
}

Please find a non-working (!) demo here:

» DEMO

[Edit] Maybe this just not possible at the moment. I think it should be possible if this feature would be implemented for the TS compiler. https://github.com/Microsoft/TypeScript/issues/16597

Upvotes: 5

Views: 676

Answers (6)

Natasha
Natasha

Reputation: 816

A working solution (yet not exactly what I have asked for) would be to use the following syntax

const HelloWorld: Component<HelloWorldProps> = component({ ... })

instead of

const HelloWorld = component<HelloWorldProps>({ ... })

Here's a little demo:

DEMO

Upvotes: 0

Pedro Arantes
Pedro Arantes

Reputation: 5379

After some days discusses and researches, it's not possible to solve your problem given your restrictions.

As you point in your question:

[Edit] Maybe this just not possible at the moment. I think it should be possible if this feature would be implemented for the TS compiler. https://github.com/Microsoft/TypeScript/issues/16597

TS won't infer generics at the moment of the function/class declaration. The idea of your issue is the same as for issue 16597:

// issue example
class Greeter<T, S> {
    greeting: T;
    constructor(message: T, message2: S) {
        this.greeting = message;
    }

}

// your issue
function component<P extends {} = {}>(config: ComponentConfig<P>): any {
  return null
}

// generalizing
const function<SOME_GIVEN_TYPE, TYPE_TO_BE_INFERED?>() {
  // TYPE_TO_BE_INFERED is defined inside de function/class.
}

Upvotes: 2

Tim Perry
Tim Perry

Reputation: 13276

Yes, this seems doable! Take a look at this playground.

The interesting types:

// Optional meaning 'could be undefined': both { x?: string } and { x: undefined }
type PickOptionalProps<T> = Pick<T, {
    [K in keyof T]-?:
      T extends Record<K, T[K]>
        ? undefined extends T[K] ? K : never
        : K
}[keyof T]>;

type DefaultsFor<P> = Partial<PickOptionalProps<P>>;

type WithoutPropsFrom<T, X> = Omit<T, keyof X>;

// Remove ? or undefined props
type WithoutOptional<T> = {
  [K in keyof T]-?: Exclude<T[K], undefined>
}

type MergePropsAndDefaults<P, D extends DefaultsFor<P>> = {    
    // Props not that aren't both optional & in the defaults stay as they are
    [K in keyof WithoutPropsFrom<P, (PickOptionalProps<P> | D)>]: P[K]
} & {
    // Optional props overridden by defaults:
    [K in keyof (PickOptionalProps<P> | D)]-?: WithoutOptional<P>[K] | D[K]
};

type ComponentConfig<
  P extends {},
  D extends DefaultsFor<P> | undefined = {}
> = D extends {}
  ? {
    displayName: string,
    defaultProps: D, 
    render(props: MergePropsAndDefaults<P, D>): any
  } 
  : {
    displayName: string, 
    render(props: P): any
  };

function component<
  P extends {} = {},
  D extends DefaultsFor<P> | undefined = undefined
>(config: ComponentConfig<P, D>): any {
  return null
}

This requires that component calls take an explicit type for the default props. It does enforce that the type is correct though, and by pulling the default props into a separate object you could pass this as simply typeof myDefaultProps.

The merging here covers a few other cases you haven't mentioned. As a minimal example:

type Props = { name?: string, age: number, optional?: number, undef: string | undefined };
type DefaultProps = { name: 'default', undef: 'other-default' };
type ResultingProps = MergePropsAndDefaults<Props, DefaultProps>;

// Gives:
type ResultingProps = {
    age: number; // Required, because it's required in the main props
    optional?: number | undefined; // Not required but no default: stays optional
} & {
    name: string; // Optional prop overridden correctly
    undef: string; // Non-optional but could-be-undefined, overridden correctly
}

Upvotes: 0

Pedro Arantes
Pedro Arantes

Reputation: 5379

Does this solution solve your problem?

DEMO

Basically I've created ComponentConfig this way:

// Keep the original implementation
type PickOptionalProps<T> = Pick<T, {
    [K in keyof T]-?: T extends Record<K, T[K]> ? never : K
}[keyof T]>

// Transform all properties as required
type TransformKeysAsRequired<T> = {
  [P in keyof T]-?: T[P];
}

interface ComponentConfig<P extends {}> {
  displayName: string,
  // I pick the optional props and transform them as required
  defaultProps?: TransformKeysAsRequired<PickOptionalProps<P>>, 
  // Just transform the props as required
  render(props: TransformKeysAsRequired<P>): any
}

Please, tell me if I misunderstood your issue.

Upvotes: 0

Arion
Arion

Reputation: 1880

I think that what you're looking for is something like this:

type HelloWorldProps = {
  name?: string
}

export default function HelloWorld({ name = "World"}: HelloWorldProps) {
    return <>HELLO {name.toUpperCase()}</>
}

The main issue you were likely having is that string isn't a valid ReactElement. Components need to return a ReactElement. We wrap the string in a fragment, which is a ReactElement, and return it. Typescript will infer the correct type for the function.

Upvotes: 0

LuDanin
LuDanin

Reputation: 729

The reason there's a compile error on your code is because, indeed, props.name could be undefined.

To fix it you can simply change the type declaration from

type GreeterProps = {
  name?: string // the ? after name means name must be undefined OR string
}

to

type GreeterProps = {
  name: string // name must be a string
}

What if you really want props.name to be able to be left undefined?

You could just change the logic inside render, an example would be:

render(props) {
  if (this.props.name === undefined) return 'You don\'t have a name =/';
  return 'HELLO ' + props.name.toUpperCase();
}
Why you would need to do that?

The answer is very simple, if props.name can be undefined you'd just call .toUpperCase on undefined. Test on your console what happens if you do (PS.: On a real app the result would be even messier).

Further notes

By the way, on a typical TypeScript + React App you'd declare default props using

  public static defaultProps = {
    ...
  };

instead of the approach you've used.

Upvotes: 2

Related Questions