Request for feedback on following TailwindCSS API

Hey guys, I am trying to introduce the power of types to TailwindCSS, and so far this is what it looks like:

<button
 className={tailwind(
  ~flex=makeFlex(~flexDirection=Flex.Column, ()),
  ~backgroundColor=Background.CongluGreen,
  ~dropShadow=Filters.DropShadowButton,
  ~height=Sizing.Height60,
  ~textColor=Text.White,
  ~width=Sizing.Width316,
  ~darkMode=true
  (),
 )}>

Benefits:

  • We can have type checking on the tailwind classes
  • Which means we can have class name validation
  • Avoiding duplicate classes
  • Contradictory classes (margin, and margin-top, for instance)

Cons that aim to eliminate:

  • The generated code looks something like this, it clearly doesn’t have zero cost bindings:
TailwindCSS.tailwind(undefined, undefined, undefined, undefined, undefined, 
undefined, /* CongluGreen */ 1, undefined, undefined, /* RoundedFull */ 0, 
undefined, undefined, undefined, undefined, undefined, 
/* DropShadowButton */ 0, undefined, undefined, undefined, undefined, 
undefined, undefined, undefined, undefined, undefined, undefined, 
undefined, undefined, undefined, undefined, undefined, undefined, 
undefined, undefined, undefined, undefined, undefined, /* White */ 8, 
undefined, undefined, undefined, /* Height60 */ 11, undefined, 
/* Width316 */ 15,  undefined, undefined)
  • The way webpack Tailwind works, we have to include all the TW classes we are using into the payload (if we want to create a general purpose TW library), because the tailwind compiler does not understand which rescript classes we’re using.

The only solution I can think of to bypass these constraints is to write a PPX, but I want to figure out if there’s non-PPX way to reduce some of the pain points while keeping the benefits.

Any feedback is welcome

Could you use a record with optional fields instead of a function with labeled arguments to avoid thousands of undefines?

1 Like

I have been saying this a few times on this forum, but will say it again just in case there’s anyone new coming in here: Writing a fully fledged PPX for checking TW classnames is not a good idea. It will absolutely trash your compile times for something you’d easily get with the default vscode TW plugin anyways. Also the API is clunkier and you’ll build arbitrary interfaces no other new dev onboarding on your codebase will be familiar with , even if it heavily leans on TWs conventions.

Also PPXes are using private compiler apis and may break if the compiler team changes something underneath.

I know ppl will still try to make it work, and it’s of course good to get into the compiler nitty gritty and learn something on the way, but I’d hate seeing ppl churn on the extra complexity just for the sake of type checking class names.

5 Likes

I understand that very well Patrick, which is why I’m trying to figure out a non-PPX way to do this.

PPXes are mega painful to use too, for instance reFormality has a PPX and it generates a LOT of runtime validation code (at the cost of writing less ReScript code). Some stuff happens in Graphql-ppx and I have no idea about how to debug the intermediate code.

I believe people resort to PPXes because the language should provide certain functionality but is missing, but this is my ill-informed opinion, it’s quite possible that there is functionality in ReScript that I am just not aware of (for instance it could be made possible via Functors or something), but again, how do we solve the graphql problem without PPX.

Coming back to the topic, is there a way to use @inline to achieve this? To force all values generated by tailwindCSS method to be inlined?

oh wait, yeah I was off topic. Sorry.

The same arguments would still apply to a compiled tailwind module + utility functions though. Why would you put a burden on the compiler doing this extra type checking work if there’s an official IDE extension that does the same thing way more aligned with the official docs?

The whole point of tailwind is that you just type a bunch of classnames and move on with doing the hard part, the actual logic of your components. At least the advantages you listed for using this approach is already a solved problem in the tailwind LSP (duplicated, ordered class names, auto completion etc)

3 Likes

But that’s the point. The power of Rescript is magnitude higher than the power of the tailwind LSP. There are things possible with it which aren’t possible with the plugin, not to mention if this is not part of the build stage then it will not be included in the code.

My devs still commit unformatted code into the codebase because the rescript formatter doesn’t kick in, and then the next dev’s commit is all messed up because his reformatter kicked in and made changes into the code which the second dev didn’t intend.

Either way, even if you think there’s no good reason to use the type system to do this, lets just say I have an itch to load ReScript type system until recompile time is at least TS level (like 30s for a recompile), and our compile time is still too fast :smile:

We make our type system do almost everything 90% of other projects would use unit testing to handle (it is one of our 3 values).

  1. Make impossible states impossible.
  2. Eliminate unit tests and make the type system handle the same constraint
  3. Never use primitive types directly (int/string/bool)

If we can make the tailwindCSS() method generate static output (without using PPX), then it would absolutely the more powerful way to go.

The closest thing I’ve seen is @inline(Inlining Constants | ReScript Language Manual) decorator because it only works on constants. I believe it could be possible to use it some way that ReScript inlines a pure function’s output. I know the compiler knows a pure function from an impure function.

It actually works on functions as well. But only when they are used inside of the file where defined.

1 Like

Without getting into the discussion of if it’s useful or not, I think one approach could be to use a functor:

Because (AFAIK) it’s not possible to merge polymorphic props in the functor itself, we work around it by exposing all the default props which can be referred to upon creation of a your custom module.

module DefaultProps = {
  type defaultProps = [#flex(int) | #bg(string)]

  let map = prop =>
    switch prop {
    | #flex(n) => "flex-" ++ string_of_int(n)
    | #bg(s) => "bg-" ++ s
    }
}

These are the things you’ll have to implement in a custom Tailwind module:

module type WithCustomProps = {
  type allProps

  let map: allProps => string
}

The created module signature:

module type Tailwind = {
  type allProps

  let tw: array<allProps> => string
}

The functor implementation:

module Make = (M: WithCustomProps): (Tailwind with type allProps = M.allProps) => {
  type allProps = M.allProps

  let tw = (propsList: array<allProps>): string => propsList->Array.joinWith(" ", M.map)
}

Your custom Tailwind module (this would be the only thing a user would have to implement, if they have some custom classes, if not they could use some default implementation):

module MyCustomTailwind = {
  type customProps = [#foo(int) | #bar(string)]
  type allProps = [DefaultProps.defaultProps /* merging happens here */ | customProps]

  let map = (prop: allProps): string =>
    switch prop {
    | #foo(n) => "foo-" ++ string_of_int(n)
    | #bar(s) => "bar-" ++ s
    // NOTE: I think you should be able to do something like:
    // `#DefaultProps.defaultProps as defaultProp
    | #flex(_) as defaultProp | #bg(_) as defaultProp => DefaultProps.map(defaultProp)
    }
}

module Tailwind = Make(MyCustomTailwind)

Tailwind.tw([#flex(1), #bg("blue"), #foo(42), #bar("baz")]) // "flex-1 bg-blue foo-42 bar-baz"

Someone more clever than me can probably help with the NOTE.

Playground:

https://rescript-lang.org/try?version=v10.1.2&code=PYBwpgdgBAQmA2AXAUMgtsAJgV3mKAImAGYCGuiACgE6gDOUAvFAN7JRSICe4UmJ5JDXpMoAbQDExPAA8AFAEsIiAJRQAPlAkAjAOZy6iakt0qAuqg55EUNKRCiQtB4wB87DlDoB3BYgDGABZQTqCsHhyaUrJyEGpuUABE0mAyALSJUADUWV5GJgD6wMQFSoixKhEaWnoG8a5JehnZuXRVAL7InehYuPjcvADqfoEAwtiGwGjCIAzMbBwD+KTw8DNtHta29gBcUCtrznMNhsYQul2oGDh4nDz4ACqkCvC+EJiiC3e8B+uWUFtEN49qRqNRSFwADy-I4NBKnEyXHo3fAAWVIAGt8Mw5Ki9sNEGMJogpusVHs5B4ni83h9fITvstVutRKiAHQw+jIerhRb3fbMo6sjmCrmbMA2OwuKDsqXimxA0RyUKzAAyCkMILBEOhotmrnJeTOuiY7k8ISO6sMaVcAEFtVw2QArYBKAmBOSZRIAGm2IEq3WufSg1NeSkw40maAUAC8wB95h4llB-MTSULmJJiMBgIplGootpQQZ8udzEn+ZzZqIxEQyBR1mz+PWhELNKmo+sLPK-UqVSC9XRDQjzqaqj4-EELWEvpEtNnc3FTUkF80ckbCsVSsoKlVC8Wh8vEkXqGvWlUAPQXqAAOQA8g8AKJ7ACSnECSgxUC4wGwXkCv7wB82jLNotwknwwBeFMEofqO8AKFiOyXteAAGEh1oIVBHE2AgNkKpAMM2WEzHu84xAUaiEXweGtmEhb6JR+xEbR2FhG4VQcJh+H0GyUpyMRPH+h0SJBrcoa0qymJgHIEnhpGJLRnGmCVMgWz+PAhF0DepBoNiIbPGG7xskCchZjEACMKi+jo+jHvA2BgIk1nzjmcgACwAEwuToxbHqQMbOWYqkAFJ0Gy8DAPoGlaTpemVEAA

EDIT: … I don’t know if this helps solve your actual problem, but perhaps the API would be less cumbersome to use.

1 Like

Codegen could also be an option that should be relatively straight forward.

You could either map each class to an inlined variable (replacing any illegal characters) or build up a type using polymorphic variables, e.g. type t = [#”flex-1” | …]. They should both be zero-cost, I think.

API for the latter would be something like:

[#”flex-1”, …]

Perhaps the proposal for untagged unions could potentially get rid of the #.

1 Like