Skip to content

Commit

Permalink
[typescript] createStyles and improved WithStyles helpers (#11609)
Browse files Browse the repository at this point in the history
* Add a createStyles function

* Add createStyles documentation to TypeScript guide

* Add InjectedStyles helper type

* Wording tweak

* Use createStyles and InjectedStyles in more tests

* Get rid of InjectedStyles and overload WithStyles to handle style object types

* Fill out missing text in typescript guide

* Put styles before props

* Restore visibility setting and use createStyles to fix type error

* Make test code more idiomatic

* Reverse order of type union so that error messages are clearer
  • Loading branch information
pelotom authored May 29, 2018
1 parent 110b6cd commit 3a5dfa9
Show file tree
Hide file tree
Showing 13 changed files with 228 additions and 150 deletions.
188 changes: 121 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,132 @@ 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:
Using `withStyles` in TypeScript can be a little tricky, but there are some utilities to make the experience as painless as possible.

```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);
// ^^^^^^
// Types of property 'flexDirection' are incompatible.
// Type 'string' is not assignable to type '"-moz-initial" | "inherit" | "initial" | "revert" | "unset" | "column" | "column-reverse" | "row"...'.
```

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.

### 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
const styles = (theme: Theme) => createStyles({
root: { /* ... */ },
paper: { /* ... */ },
button: { /* ... */ },
});

```js
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;
};
}
```

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';

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

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

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 +143,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 +160,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 +178,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 +208,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 +224,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) {
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);
});
});
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';
13 changes: 9 additions & 4 deletions packages/material-ui/src/styles/withStyles.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,22 @@ 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> & {
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>>;
innerRef?: React.Ref<any> | React.RefObject<any>;
}

export default function withStyles<ClassKey extends string>(
style: StyleRules<ClassKey> | StyleRulesCallback<ClassKey>,
style: StyleRulesCallback<ClassKey> | StyleRules<ClassKey>,
options?: WithStylesOptions<ClassKey>,
): {
<P extends ConsistentWith<P, StyledComponentProps<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

0 comments on commit 3a5dfa9

Please sign in to comment.