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

[typescript] createStyles and improved WithStyles helpers #11609

Merged
merged 11 commits into from
May 29, 2018
185 changes: 118 additions & 67 deletions docs/src/pages/guides/typescript/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,129 @@ Have a look at the [Create React App with TypeScript](https://github.com/mui-org

## Usage of `withStyles`

The usage of `withStyles` in TypeScript can be a little tricky, so it's worth showing some examples. You can first call `withStyles()` to create a decorator function, like so:
The usage of `withStyles` in TypeScript can be a little tricky for a number of reasons, but
Copy link
Member

Choose a reason for hiding this comment

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

Is there something missing after the "but"? 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

Oops 😬


```js
const decorate = withStyles(({ palette, spacing }) => ({
### Using `createStyles` to defeat type widening

A frequent source of confusion is TypeScript's [type widening](https://blog.mariusschulz.com/2017/02/04/typescript-2-1-literal-type-widening), which causes this example not to work as expected:

```ts
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
}
};

withStyles(styles); // Error!
```

The problem is that the type of the `flexDirection` property is inferred as `string`, which is too arbitrary. To fix this, you can pass the styles object directly to `withStyles`:

```ts
withStyles({
root: {
display: 'flex',
flexDirection: 'column',
},
});
```

However type widening rears its ugly head once more if you try to make the styles depend on the theme:

```ts
withStyles(({ palette, spacing }) => ({
root: {
display: 'flex',
flexDirection: 'column',
padding: spacing.unit,
backgroundColor: palette.background.default,
color: palette.primary.main
color: palette.primary.main,
},
}));
```

This can then subsequently be used to decorate either a stateless functional component or a class component. Suppose we have in either case the following props:
This is because TypeScript [widens the return types of function expressions](https://github.com/Microsoft/TypeScript/issues/241).

Because of this, we recommend using our `createStyles` helper function to construct your style rules object:

```ts
// Non-dependent styles
const styles = createStyles({
root: {
display: 'flex',
flexDirection: 'column',
},
});

// Theme-dependent styles
const styles = ({ palette, spacing }: Theme) => createStyles({
root: {
display: 'flex',
flexDirection: 'column',
padding: spacing.unit,
backgroundColor: palette.background.default,
color: palette.primary.main,
},
});
```

`createStyles` is just the identity function; it doesn't "do anything" at runtime, just helps guide type inference at compile time.

```js
### Augmenting your props using `WithStyles`

Since a component decorated with `withStyles(styles)` gets a special `classes` prop injected, you will want to define its props accordingly:

```ts
interface Props {
text: string;
type: TypographyProps['type'];
color: TypographyProps['color'];
};
// non-style props
foo: number;
bar: boolean;
// injected style props
classes: {
root: string;
paper: string;
button: string;
};
}

const styles = (theme: Theme) => createStyles({
root: { /* ... */ },
paper: { /* ... */ },
button: { /* ... */ },
});
```

However this isn't very [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) because it requires you to maintain the class names (`'root'`, `'paper'`, `'button'`, ...) in two different places. We provide a type operator `WithStyles` to help with this, so that you can just write

```ts
import { WithStyles } from '@material-ui/core';

interface Props extends WithStyles<typeof styles> {
foo: number;
bar: boolean;
}

const styles = (theme: Theme) => createStyles({
Copy link
Member

Choose a reason for hiding this comment

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

Maybe first styles then Props so you know what typeof styles refers to? Also "declare before use" lint error! 😁

Copy link
Member Author

Choose a reason for hiding this comment

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

Types can refer to things in any order, and at least in my code I tend to put all type declarations at the top of the file. But I don't feel too strongly about it.

root: { /* ... */ },
paper: { /* ... */ },
button: { /* ... */ },
});
```

Functional components are straightforward:
### Decorating components

Applying the `withStyles(styles)` decorator as a function works as expected:

```jsx
const DecoratedSFC = decorate<Props>(({ text, type, color, classes }) => (
```tsx
const DecoratedSFC = withStyles(styles)(({ text, type, color, classes }: Props) => (
<Typography variant={type} color={color} classes={classes}>
{text}
</Typography>
));
```

Class components are a little more cumbersome. Due to a [current limitation in TypeScript's decorator support](https://github.com/Microsoft/TypeScript/issues/4881), `withStyles` can't be used as a class decorator. Instead, we decorate a class component like so:

```jsx
import { WithStyles } from '@material-ui/core/styles';

const DecoratedClass = decorate(
class extends React.Component<Props & WithStyles<'root'>> {
const DecoratedClass = withStyles(styles)(
class extends React.Component<Props> {
render() {
const { text, type, color, classes } = this.props
return (
Expand All @@ -56,11 +140,13 @@ const DecoratedClass = decorate(
);
```

When your `props` are a union, Typescript needs you to explicitly tell it the type, by providing a generic `<Props>` parameter to `decorate`:
Unfortunately due to a [current limitation of TypeScript decorators](https://github.com/Microsoft/TypeScript/issues/4881), `withStyles` can't be used as a decorator in TypeScript.

```jsx
import { WithStyles } from '@material-ui/core/styles';
### Union props

When your `props` are a union, Typescript needs you to explicitly tell it the type, by providing a generic `<Props>` parameter to `decorate`:

```tsx
interface Book {
category: "book";
author: string;
Expand All @@ -71,10 +157,12 @@ interface Painting {
artist: string;
}

type Props = Book | Painting;
type BookOrPainting = Book | Painting;

const DecoratedUnionProps = decorate<Props>( // <-- without the type argument, we'd get a compiler error!
class extends React.Component<Props & WithStyles<'root'>> {
type Props = BookOrPainting & WithStyles<typeof styles>;

const DecoratedUnionProps = withStyles(styles)<BookOrPainting>( // <-- without the type argument, we'd get a compiler error!
class extends React.Component<Props> {
render() {
const props = this.props;
return (
Expand All @@ -87,51 +175,14 @@ const DecoratedUnionProps = decorate<Props>( // <-- without the type argument, w
);
```


### Injecting Multiple Classes

Injecting multiple classes into a component is as straightforward as possible. Take the following code for example. The classes `one` and `two` are both available with type information on the `classes`-prop passed in by `withStyles`.

```jsx
import { Theme, withStyles, WithStyles } from "material-ui/styles";
import * as React from "react";

const styles = (theme: Theme) => ({
one: {
backgroundColor: "red",
},
two: {
backgroundColor: "pink",
},
});

type Props = {
someProp: string;
};

type PropsWithStyles = Props & WithStyles<keyof ReturnType<typeof styles>>;

const Component: React.SFC<PropsWithStyles> = ({
classes,
...props
}: PropsWithStyles) => (
<div>
<div className={classes.one}>One</div>
<div className={classes.two}>Two</div>
</div>
);

export default withStyles(styles)<Props>(Component);
```

## Customization of `Theme`

When adding custom properties to the `Theme`, you may continue to use it in a strongly typed way by exploiting
[Typescript's module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation).

The following example adds an `appDrawer` property that is merged into the one exported by `material-ui`:

```js
```ts
import { Theme } from '@material-ui/core/styles/createMuiTheme';
import { Breakpoint } from '@material-ui/core/styles/createBreakpoints';

Expand All @@ -154,7 +205,7 @@ declare module '@material-ui/core/styles/createMuiTheme' {

And a custom theme factory with additional defaulted options:

```js
```ts
import createMuiTheme, { ThemeOptions } from '@material-ui/core/styles/createMuiTheme';

export default function createMyTheme(options: ThemeOptions) {
Expand All @@ -170,7 +221,7 @@ export default function createMyTheme(options: ThemeOptions) {

This could be used like:

```js
```ts
import createMyTheme from './styles/createMyTheme';

const theme = createMyTheme({ appDrawer: { breakpoint: 'md' }});
Expand Down
1 change: 1 addition & 0 deletions packages/material-ui/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export {
Theme,
withStyles,
WithStyles,
createStyles,
withTheme,
WithTheme,
} from './styles';
Expand Down
1 change: 1 addition & 0 deletions packages/material-ui/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { colors };
export {
createGenerateClassName,
createMuiTheme,
createStyles,
jssPreset,
MuiThemeProvider,
withStyles,
Expand Down
11 changes: 11 additions & 0 deletions packages/material-ui/src/styles/createStyles.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { CSSProperties, StyleRules } from './withStyles';

/**
* This function doesn't really "do anything" at runtime, it's just the identity
* function. Its only purpose is to defeat TypeScript's type widening when providing
* style rules to `withStyles` which are a function of the `Theme`.
*
* @param styles a set of style mappings
* @returns the same styles that were passed in
*/
export default function createStyles<S extends StyleRules>(styles: S): S;
5 changes: 5 additions & 0 deletions packages/material-ui/src/styles/createStyles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @flow

export default function createStyles(s: Object) {
Copy link
Member

Choose a reason for hiding this comment

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

It's funny, life is a circle, we had this function in v1.0.0-alpha.x.

Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't know that... did it serve the same purpose?

Copy link
Member

Choose a reason for hiding this comment

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

Nop, we used it to perform some operations on the styles.

return s;
}
9 changes: 9 additions & 0 deletions packages/material-ui/src/styles/createStyles.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { assert } from 'chai';
import { createStyles } from '.';

describe('createStyles', () => {
it('is the identity function', () => {
const styles = {};
assert.strictEqual(createStyles(styles), styles);
Copy link
Member

Choose a reason for hiding this comment

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

This is a little nit-picking, but does strictEqual compare properties or checks that the passed in arguments are the same "pointer". Meaning, the result of createStyles and styles is the same reference.

If not, we could add another tests that just check for ===.

Copy link
Member

@oliviertassinari oliviertassinari May 28, 2018

Choose a reason for hiding this comment

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

strictEqual uses === 👍 .

});
});
1 change: 1 addition & 0 deletions packages/material-ui/src/styles/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { default as createGenerateClassName } from './createGenerateClassName';
export { default as createMuiTheme, Theme, Direction } from './createMuiTheme';
export { default as jssPreset } from './jssPreset';
export { default as MuiThemeProvider } from './MuiThemeProvider';
export { default as createStyles } from './createStyles';
export {
default as withStyles,
WithStyles,
Expand Down
1 change: 1 addition & 0 deletions packages/material-ui/src/styles/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export { default as createGenerateClassName } from './createGenerateClassName';
export { default as createMuiTheme } from './createMuiTheme';
export { default as jssPreset } from './jssPreset';
export { default as MuiThemeProvider } from './MuiThemeProvider';
export { default as createStyles } from './createStyles';
export { default as withStyles } from './withStyles';
export { default as withTheme } from './withTheme';
11 changes: 8 additions & 3 deletions packages/material-ui/src/styles/withStyles.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,14 @@ export interface WithStylesOptions<ClassKey extends string = string> extends JSS

export type ClassNameMap<ClassKey extends string = string> = Record<ClassKey, string>;

export interface WithStyles<ClassKey extends string = string> extends Partial<WithTheme> {
classes: ClassNameMap<ClassKey>;
}
export type WithStyles<T extends string | StyleRules | StyleRulesCallback> = Partial<WithTheme> & {
Copy link
Member

Choose a reason for hiding this comment

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

This is absolutely awesome and a good use case for conditional types 🤩

Copy link
Contributor

Choose a reason for hiding this comment

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

Removing the default = string is a breaking change though?

Copy link
Member Author

Choose a reason for hiding this comment

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

@geirsagberg right you are. I've opened #11808 to fix that.

classes: ClassNameMap<
T extends string ? T :
T extends StyleRulesCallback<infer K> ? K :
T extends StyleRules<infer K> ? K :
never
>;
};

export interface StyledComponentProps<ClassKey extends string = string> {
classes?: Partial<ClassNameMap<ClassKey>>;
Expand Down
10 changes: 6 additions & 4 deletions packages/material-ui/src/withWidth/withWidth.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import * as React from 'react';
import { Grid } from '..';
import { Theme } from '../styles';
import { Theme, createStyles } from '../styles';
import withStyles, { WithStyles } from '../styles/withStyles';
import withWidth, { WithWidthProps } from '../withWidth';

const styles = (theme: Theme) => ({
const styles = (theme: Theme) => createStyles({
root: {
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.palette.common.black,
},
});

interface IHelloProps {
interface IHelloProps extends WithWidthProps, WithStyles<typeof styles> {
name?: string;
}

export class Hello extends React.Component<IHelloProps & WithWidthProps & WithStyles<'root'>> {
export class Hello extends React.Component<IHelloProps> {
public static defaultProps = {
name: 'Alex',
};
Expand Down
Loading