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

suggestion for TS typing of withStyles #8704

Closed
bradzacher opened this issue Oct 15, 2017 · 10 comments
Closed

suggestion for TS typing of withStyles #8704

bradzacher opened this issue Oct 15, 2017 · 10 comments

Comments

@bradzacher
Copy link

bradzacher commented Oct 15, 2017

Having a look at your recently updated doc for typescript: https://github.com/callemall/material-ui/blob/v1-beta/docs/src/pages/guides/typescript.md, I wanted to make a suggestion in regards to how to type the decorator.

Within my app I've actually wrapped your function in my own function + typings for two reasons: 1) so that I can import all the relevant interfaces/functions from a single place, and 2) so I can apply decorator typings to the function.

Here is my code for my wrapper:

import * as React from 'react'
import { withStyles as muiWithStyles, /* withTheme, */ Theme } from 'material-ui/styles'
import { WithStylesOptions } from 'material-ui/styles/withStyles'

import JSSProperties from './jssProperties'

export type StyleSheet<T extends string> = {
    [k in T] : JSSProperties
}
export type StyleList<T extends string> = {
    [k in T] : string
}
type ThemeThunk<T> = T | ((theme : Theme) => T)

export interface StyledComponentProps<T> {
    className ?: string
    classes ?: StyleList<keyof T>
    style ?: Partial<React.CSSProperties>
    theme ?: Theme
}

export function createStyleSheet<T extends string>(list : ThemeThunk<StyleSheet<T>>) {
    return list as any as StyleSheet<T>
}

type withStylesFn = <TS extends string>(stylesheet : StyleSheet<TS>, options ?: WithStylesOptions) =>
    <TP, TC extends React.ComponentClass<TP>>(component : TC) => TC

export const withStyles : withStylesFn =
    (stylesheet, options) => {
        const fn = muiWithStyles(stylesheet as any, options)

        return (component : any) => {
            return fn(component) as any
        }
    }

And usage:

import { createStyleSheet, withStyles, StyledComponentProps } from 'lib/createStyleSheet'

const styles = createStyleSheet({
    classA: {
        opacity: 0.3,
    },
})

export interface Props extends StyledComponentProps<typeof styles> {
    propA : string
}

@withStyles(styles)
export class Comp extends React.Components<Props, {}> {
    render() {
        return (
            <div className={this.props.classes.classA}>Look ma, it works</div>
        )
    }
}

Now I know it might look a little bit weird because there are a few functions that do nothing except for change types, but I found this is the easiest way to provide nice typings around the place.

createStyleSheet exists for a few reasons;

  1. it gives us intellisense for css props when creating a style sheet.
    image
  2. it maintains the list of keys on the object (using generics):
    image

withStylesFn's typings are setup so that they work for a decorator, and note that they will work as well as a HOC (though they aren't as strict as some people might like).

Note that I don't do any strict typing around the props for the component, this is because at the end of the day - who cares what props are on the component passed in.
If the consumer forgets to put a classes prop on their component props, their component won't work and they won't be able to access the classes in their render because typescript will throw an error at them.
If they don't actually use a theme prop, why force them to put it on their props? It seems like it's an annoying an confusing thing to do when they don't care and neither does react.

This makes it easier to do the typings because the current typings requires the component to have classes and theme, then returns a component with them optional.

Also important to note how the react typescript typings are written:

props: Readonly<{ children?: ReactNode }> & Readonly<P>

And typescript's Readonly def:

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}

Note that Readonly converts all optional keys to be required (because there's no ?:!!!).
Which means that we can define the props on the class as

interface Props {
    classes ?: xxx//...
}

And then within the code we can use it without undefined errors, because of the readonly interface.


For posterity's sake, this is the jssProperties definition file.
It's just an extension of the css properties with some extra typings to work with the JSS's default plugin set that you add to the JSS config automatically:

/* eslint-disable */
import * as React from 'react'

interface PsuedoCss {
    // pseudo-classes
    ':active' ?: React.CSSProperties
    ':any' ?: React.CSSProperties
    ':checked' ?: React.CSSProperties
    ':default' ?: React.CSSProperties
    ':disabled' ?: React.CSSProperties
    ':empty' ?: React.CSSProperties
    ':enabled' ?: React.CSSProperties
    ':first' ?: React.CSSProperties
    ':first-child' ?: React.CSSProperties
    ':first-of-type' ?: React.CSSProperties
    ':fullscreen' ?: React.CSSProperties
    ':focus' ?: React.CSSProperties
    ':hover' ?: React.CSSProperties
    ':indeterminate' ?: React.CSSProperties
    ':in-range' ?: React.CSSProperties
    ':invalid' ?: React.CSSProperties
    ':last-child' ?: React.CSSProperties
    ':last-of-type' ?: React.CSSProperties
    ':left' ?: React.CSSProperties
    ':link' ?: React.CSSProperties
    ':only-child' ?: React.CSSProperties
    ':only-of-type' ?: React.CSSProperties
    ':optional' ?: React.CSSProperties
    ':out-of-range' ?: React.CSSProperties
    ':read-only' ?: React.CSSProperties
    ':read-write' ?: React.CSSProperties
    ':required' ?: React.CSSProperties
    ':right' ?: React.CSSProperties
    ':root' ?: React.CSSProperties
    ':scope' ?: React.CSSProperties
    ':target' ?: React.CSSProperties
    ':valid' ?: React.CSSProperties
    ':visited' ?: React.CSSProperties
    // TODO
    // ':dir()' ?: React.CSSProperties
    // ':lang()' ?: React.CSSProperties
    // ':not()' ?: React.CSSProperties
    // ':nth-child()' ?: React.CSSProperties
    // ':nth-last-child()' ?: React.CSSProperties
    // ':nth-last-of-type()' ?: React.CSSProperties
    // ':nth-of-type()' ?: React.CSSProperties

    // pseudo-selectors
    '::after' ?: React.CSSProperties
    '::before' ?: React.CSSProperties
    '::cue' ?: React.CSSProperties
    '::first-letter' ?: React.CSSProperties
    '::first-line' ?: React.CSSProperties
    '::selection' ?: React.CSSProperties
    '::backdrop ' ?: React.CSSProperties
    '::placeholder ' ?: React.CSSProperties
    '::marker ' ?: React.CSSProperties
    '::spelling-error ' ?: React.CSSProperties
    '::grammar-error ' ?: React.CSSProperties
}

interface JssProps {
    '@global' ?: React.CSSProperties & PsuedoCss
    extend ?: string
    composes ?: string | string[]
}

type css = React.CSSProperties

interface JssExpand {
    animation : {
        delay: css['animationDelay']
        direction: css['animationDirection']
        duration: css['animationDuration']
        iterationCount: css['animationIterationCount']
        name: css['animationName']
        playState: css['animationPlayState']
        timingFunction: any
    } | css['animation']
    background : {
        attachment: css['backgroundAttachment']
        color: css['backgroundColor']
        image: css['backgroundImage']
        position: css['backgroundPosition'] | number[] // Can be written using array e.g. `[0 0]`
        repeat: css['backgroundRepeat']
        size: css['backgroundSize'] | css['backgroundSize'][] // Can be written using array e.g. `['center' 'center']`
    } | css['background']
    border : {
        color: css['borderColor']
        style: css['borderStyle']
        width: css['borderWidth']
    } | css['border']
    boxShadow : {
        x: any
        y: any
        blur: any
        spread: any
        color: css['color']
        inset ?: 'inset' // If you want to add inset you need to write "inset: 'inset'"
    } | css['boxShadow']
    flex : {
        basis: css['flexBasis']
        direction: css['flexDirection']
        flow: css['flexFlow']
        grow: css['flexGrow']
        shrink: css['flexShrink']
        wrap: css['flexWrap']
    } | css['flex']
    font : {
        family: css['fontFamily']
        size: css['fontSize']
        stretch: css['fontStretch']
        style: css['fontStyle']
        variant: css['fontVariant']
        weight: css['fontWeight']
    } | css['font']
    listStyle : {
        image: css['listStyleImage']
        position: css['listStylePosition']
        type: css['listStyleType']
    } | css['listStyle']
    margin : {
        bottom: css['marginBottom']
        left: css['marginLeft']
        right: css['marginRight']
        top: css['marginTop']
    } | css['margin']
    padding : {
        bottom: css['paddingBottom']
        left: css['paddingLeft']
        right: css['paddingRight']
        top: css['paddingTop']
    } | css['padding']
    outline :  {
        color: css['outlineColor']
        style: 'none' | 'hidden' | 'dotted' | 'dashed' | 'solid' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset'
        width: any
    } | css['outline']
    textShadow : {
        x: any
        y: any
        blur: any
        color: css['color']
    } | css['textShadow']
    transition : {
        delay: css['transitionDelay']
        duration: css['transitionDuration']
        property: css['transitionProperty']
        timingFunction: css['transitionTimingFunction']
    } | css['transition']
}
type JssExpandArr = {
    [k in keyof JssExpand] ?: JssExpand[k] | JssExpand[k][]
}

export type CSSProperties = React.CSSProperties & PsuedoCss & JssProps & JssExpandArr
export default CSSProperties
@pelotom
Copy link
Member

pelotom commented Oct 16, 2017

Re. using withStyles as a decorator, see discussion in #8447... TL;DR many people do use strict type checking and want withStyles to have the correct type, which is impossible if you want to use it as a decorator.

@bradzacher
Copy link
Author

in this definition though, withStyles takes and returns the correct type, or as near as possible.
The typings here are strict and give you the exact types that the consumer requires, because they give the exact types the consumer defines.

using the above typings:

@withStyles(styles)
export class CompDeco extends React.Components<Props, {}> { }

const CompHoc = withStyles(styles)(CompDeco)

// as far as TS is concerned, typeof CompDeco === typeof CompHoc

The above definition is exactly how mobx-react does typings for its observer decorator, and its inject decorators (note the inject also adds some additional statics, hence the intersection def).

This definition lets a consumer consume it exactly how they want - strict or loose.

@pelotom
Copy link
Member

pelotom commented Oct 16, 2017

Your example doesn't compile with strictNullChecks:

@withStyles(styles)
export class Comp extends React.Component<Props, {}> {
    render() {
        return (
            <div className={this.props.classes.classA}>Look ma, it works</div>
//                          ^^^^^^^^^^^^^^^^^^
//                          Object is possibly 'undefined'.
        )
    }
}

For the typing to be correct, classes must be known to be defined within the definition of the component, and optional when constructing an element of the component.

@pelotom
Copy link
Member

pelotom commented Oct 16, 2017

// as far as TS is concerned, typeof CompDeco === typeof CompHoc

This is required by TypeScript decorators in their current form, which is why we can't use them if we want the types to be correct. Because the actual semantics of withStyles is that C is never the same as withStyles(styles)(C). C has a defined classes prop, and withStyles(styles)(C) has a classes prop which is possibly undefined.

@bradzacher
Copy link
Author

ah, I apologise, had my config set wrong. I thought I had strictNullChecks on.
though this issue is pretty easily worked around:

@withStyles(styles)
export class Comp extends React.Components<Props, {}> {
    render() {
        const classes = this.props.classes!
        return (
            <div className={classes.classA}>Look ma, it works</div>
        )
    }
}

I recognise that it's required by typescript, I raised an issue and spent some time discussing with them (amongst other things) a few weeks ago (because I thought it was a pretty silly way to force the typings, but that's another discussion [along with the way they do generic constructors...]).

personally though, I feel like it'd be better to provide a typing for the decorator, rather than no typing for the decorator at all.

without a definition, consumers have to create their own wrapper with a decorator typing (like I have), or they HAVE to use it your way.

At least with a decorator definition, people can choose to use it if they like, or in the case that it isn't strict enough, they can use the HOC definition.


that aside though.
It'd be good if to work in the JSS props changes in for better typing in that department.

also it'd be good to relocate (or re-export) some of the defs so that it can be imported from a single place (as opposed to multiple files which is required right now)

@pelotom
Copy link
Member

pelotom commented Oct 16, 2017

though this issue is pretty easily worked around:

const classes = this.props.classes!

Yes, this was the compromise I struck in #8354, and prior to 1.0.0-beta.16 withStyles was usable as a class decorator. Then people complained in #8447 and elsewhere that the typing was wrong. As I pointed out there, one has to choose whether one wants the typings to be correct or whether one wants withStyles to be usable as a decorator. And the consensus based on the comments on that issue seemed to be that people preferred the former.

I recognise that it's required by typescript, I raised an issue and spent some time discussing with them (amongst other things) a few weeks ago (because I thought it was a pretty silly way to force the typings, but that's another discussion [along with the way they do generic constructors...]).

See Typescript #4881. My understanding is they're waiting for decorators to become a stage 3 proposal before they'll reimplement them properly.

personally though, I feel like it'd be better to provide a typing for the decorator, rather than no typing for the decorator at all.

without a definition, consumers have to create their own wrapper with a decorator typing (like I have), or they HAVE to use it your way.

At least with a decorator definition, people can choose to use it if they like, or in the case that it isn't strict enough, they can use the HOC definition.

I'm not sure what you mean. AFAIK there's no way to "just add support for the decorator" without also making the typings incorrect in one way or another. If we make it usable as a decorator, people can't opt to "use the HOC definition" and get the strictness they want. I'd love to see a proposed typing that proves me wrong on this, though!


that aside though.
It'd be good if to work in the JSS props changes in for better typing in that department.

also it'd be good to relocate (or re-export) some of the defs so that it can be imported from a single place (as opposed to multiple files which is required right now)

You might want to open a separate, more targeted issue (or even a PR!) that disentangles this proposal from the withStyles decorator part.

@bradzacher
Copy link
Author

AFAIK there's no way to "just add support for the decorator" without also making the typings incorrect in one way or another.

I was going to say that function overloads will do what you want, because i've seen and have used them before for these decorator overloads.

However upon lengthy testing and investigation, it appears that typescript does its annoying optimisation and just chooses the most generic function definition for you. (another one of their no doubt "helpful" compile optimisations).
And by golly does it really really stretch itself to use the most generic def (you can see my testing here: https://codesandbox.io/s/lykkn6mv29?module=%2Ftest.tsx&view=editor).
in my test example, it'll choose to use the definition cmon<{}, {}, x> over using cmon<Props, { hi : true }>

You can't even hint to the compiler an order in which to attempt them by defining them in a certain order.

The best way I found to make it work is as follows:

// hoc
declare function fn<P extends WithStylesInputProps>(clazz : React.StatelessComponent<P> | React.ComponentClass<P> | React.ClassicComponentClass<P>) : React.ComponentClass<StyledComponentProps>

// deco
declare function fn<TC>(comp : TC) : TC


const TestHoc = fn(
    class testHoc extends React.Component<Props & WithStylesInputProps, {}> {
    } as React.ComponentClass<Props & WithStylesInputProps> // note the type cast
)

@fn
class TestDeco extends React.Component<Props & StyledComponentProps, {}> { }

Whilst this works and lets you have the dual definitions, it mildly inconveniences those that want the super strict definitions.

You might want to open a separate, more targeted issue (or even a PR!) that disentangles this proposal from the withStyles decorator part.

I might just do that

@pelotom
Copy link
Member

pelotom commented Oct 16, 2017

Whilst this works and lets you have the dual definitions, it mildly inconveniences those that want the super strict definitions.

I think “mildly inconveniences” is putting it mildly ;)

@pelotom
Copy link
Member

pelotom commented Oct 16, 2017

We used method overloads when we were allowing decorators; this was the definition:

export default function withStyles<Names extends string>(
  style: StyleRules<Names> | StyleRulesCallback<Names>,
  options?: WithStylesOptions
): {
  <P>(
    component: React.StatelessComponent<P & WithStyles<Names>>
  ): React.ComponentType<P & StyledComponentProps<Names>>;
  <P, C extends React.ComponentClass<P & StyledComponentProps<Names>>>(
    component: C
  ): C;
};

This gave the correct typing when using it with an SFC, and the decorator-friendly-but-slightly-wrong version for class components. Unfortunately there’s just no way of getting around the fact that, from the type system’s point of view

withStyles(styles)(class C {})

and

@withStyles(styles)
class C {}

are completely indistinguishable; there’s no way to give an alternate typing for just the situation where you’re using a decorator.

@bradzacher
Copy link
Author

unfortunately it's a bit annoying that they're leaving it in its broken state.
I mean, I understand why they'd choose to, but as a user I'm also annoyed that they don't add something to make it easier to work with right now (that'd be "transportable" into future implementations).
I.e. a simple token to differentiate a decorator def from a standard def a la

function @withStyles(...) // deco def
function withStyles(...) // non deco def

I digress though.
I'll close this as there's no resolution for this at this point in time.

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

No branches or pull requests

3 participants