Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add defaultProps helper function #1794

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

lunafoxfire
Copy link

Summary

mergeProps provides the current standard way of adding default props to a component, but it has some weaknesses when it comes to typing (see #1526). defaultProps essentially adds a subset of mergeProps functionality but with more restricted typing for the specific case of providing default props to a component. It has two main advantages:

  • Exact typings are preserved (e.g. "start" | "center" | "end" does not get transformed into string)
  • The default props are restricted to providing keys that are already defined as Component props. This provides safety against accidentally providing defaults for props that are no longer valid.

Even though technically the functionality of defaultProps can be achieved with mergeProps, defaultProps provides a much nicer typescript experience for an extremely common use case.

How did you test this change?

  • Added tests to packages/solid/test/component.spec.ts
  • Added type tests to packages/solid/test/component.type-tests.ts
  • Ran the full test suite with pnpm test

@changeset-bot
Copy link

changeset-bot bot commented Jun 29, 2023

⚠️ No Changeset found

Latest commit: 596a719

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ryansolid
Copy link
Member

Ok. This is interesting. I can see value in special casing it for types. I generally don't like increasing the API surface but I have a suspicion this is the 90% case so that seems like a good add. I'd love to get some more feedback on this feature, but definitely something I think we'd consider.

@atk
Copy link
Contributor

atk commented Jul 1, 2023

I believe we should try to solve this within the type system before admitting defeat and resorting to extend the API, e.g. using a switch generic like <DEFAULTS extends boolean = false> that needs to be set to true to toggle the "defaults" mode.

@otonashixav
Copy link
Contributor

otonashixav commented Jul 1, 2023

I can see enough reason for this to be included, albeit maybe with some iteration of its design. As I see it, mergeProps is analogous to a spread operator that works with how solid implements reactivity: mergeProps(a, b, c) <==> {...a, ...b, ...c}, so this could try to resemble default parameters: defaultProps({a: 1}, props) <==> ({a = 1, b, c}: Props => ...).

With mergeProps and the spread operator, we don't care so much about the types of the other props. With defaultProps, like default parameters, we would constrain the type of each prop; using satisfies with mergeProps would give you the same result.

Personally I would reverse the parameters (props first, then defaults), so that the default values follow their "declaration" as they do with default parameters, and also exclude props which are required since providing a default for those props is meaningless.

@lunafoxfire
Copy link
Author

lunafoxfire commented Jul 1, 2023

Personally I would reverse the parameters (props first, then defaults), so that the default values follow their "declaration" as they do with default parameters, and also exclude props which are required since providing a default for those props is meaningless.

I agree with this. I've been using this as a helper function quite extensively in my current project and having the signature be defaultProps(props, defaults) makes way more sense (I actually ended up switching the order in my own code). You get the type inference immediately, which helps when you start typing out the defaults. Also it's a bit nicer formatting wise with prettier.

I implemented it this way originally to mirror the current mergeProps method but I think swapping the arguments is the way to go. Here's an example of how I've been using this (with swapped arguments version):

export type SliderProps = {
    label?: string;
    value?: number;
    onChange?: (value: number) => void;
    min?: number;
    max?: number;
    step?: number;
    origin?: "start" | "center";
    class?: string;
};
const Slider: Component<SliderProps> = (unresolvedProps) => {
    const props = defaultProps(unresolvedProps, {
        label: "",
        min: 0,
        max: 100,
        step: 1,
        origin: "start",
        class: "",
    });
    // blah...
};

EDIT: I went ahead and pushed a commit to swap the argument order. But this can be reverted if opinions differ.

@lunafoxfire
Copy link
Author

I believe we should try to solve this within the type system before admitting defeat and resorting to extend the API, e.g. using a switch generic like <DEFAULTS extends boolean = false> that needs to be set to true to toggle the "defaults" mode.

I see what you're saying but if I can add my 2 cents about this: I think defaultProps still makes a lot of sense purely semantically, even if the typing issues with mergeProps can be resolved. Sometimes restricting a very general function to a more narrow domain to handle a common use case makes a lot of sense. However, I also know I probably would not have made this PR if I was using pure JS without typescript.

That's just my thoughts. I definitely understand the concerns with extending the API though.

Comment on lines +268 to +274
export type DefaultProps<T, K extends keyof T> = MergeProps<[Required<Pick<T, K>>, T]>;
export function defaultProps<T, K extends keyof T>(
props: T,
defaults: Required<Pick<T, K>>
): DefaultProps<T, K> {
return mergeProps(defaults, props);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type DefaultableProps<T, K extends keyof T> = {
  [P in K]-?: undefined extends T[P] ? Exclude<T[P], undefined> : never;
}
- Required<Pick<T, K>>
+ DefaultableProps<T, K>

I haven't fully tested this but this should allow only defaults that aren't already necessary in props.

@oddcelot
Copy link

Hey there, just tried out the defaultProps() in this playground in CounterWithOldDefaults.tsx and there may be an issue with the signature of the return type.

type Props = {
  initialCount: number;
  firstName?: string;
  middleName?: string | undefined;
  lastName?: string;
  notDeclared?: string
};

const $someNumber = 3;

export const myDefaultProps = {
  firstName: $someNumber >= 3 ? "John" : undefined,
  middleName: "Ron",
  lastName: $someNumber > 3 ? "Doe" : undefined,
} as const satisfies Partial<Props>;

const props = defaultProps(props, myDefaultProps);

So given this block here 👆, this should result in something like this, no?

expected result actual result
const props: {
    initialCount: number;
    firstName: string | undefined;
    middleName: string;
    lastName: string | undefined;
    notDeclared?: string | undefined;
}
const props: {
    firstName: string;
    middleName: string;
    lastName: string;
    initialCount: number;
    notDeclared?: string | undefined;
}
(actual order of props from result)

In this example firstName and lastName can be still undefined and should be returned as such.
Also myDefaultProps is marked as error, since it has unions with undefinedin it (since it uses Required<T> )
This may be a bit of a synthetic issue, but I managed to adress this issue I think… (also the order of the props is still the original one)
Maybe have a look in the Counter.tsx in the playground

@oddcelot
Copy link

Worked the bit out with where only keys that are actually in the props should be allowed in the defaults I think, like @otonashixav mentioned.
So, yeah, better errors 😅

Check out the new playground

Note

A thought: is defaultProps a bit misleading? I mean these are not the default props but rather a merge of props with defaults… hence the name I used

Tip

You now get proper TS suggestions with this (maybe I also missed something, but didn't work for me somehow)

image

@RomainLanz
Copy link

Hey all! 👋🏻

I had exactly the same issue today when I tried to use SolidJS and do a simple Button component.

Using the default API with mergeProps make the following code not type-safe because the result of mergeProps remove the proper type for the type attribute.

import { mergeProps } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'

interface ButtonProps {
  type?: 'button' | 'submit' | 'reset'
  children: JSX.Element
}

export function Button(initialProps: ButtonProps) {
  const props = mergeProps({ type: 'button' }, initialProps)

  return <button type={props.type}>{props.children}</button>
}

This result in the following TypeScript error:

TS2322: Type string is not assignable to type 'button' | 'submit' | 'reset' | undefined

I am wondering if there is any chance to have this PR merged or maybe looked at again?
I believe many beginners will have those kind of issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants