DEV Community

Cover image for Metapolymorphic providers
Mike Solomon
Mike Solomon

Posted on • Edited on

Metapolymorphic providers

In a recent article Horizontal and Vertical Events, I talked about some of the ways that events can be pushed to "horizontal" and "vertical" boundaries of an application, where horizontal spreads an event across a component and vertical pushes an event into nested components.

In the article, I mentioned briefly that we can use Event-s to create a provider system in our application. In this article I'd like to unpack that assertion. I'll start by defining what I mean by provider and then show how it can be implemented using something I call "metapolymorphism" or "wild-card polymorphism."

Providers 101

When I say "provider", I'm borrowing a term from React. In React, one can create contexts, and then use providers to get values from the context in any sub-element. This is absolutely essential when working with things like authorization tokens: we do not want to write logic to constantly push tokens layers deep down an application. Instead, we want to pick them off where we need them and, importantly, have the compiler complain if they are of the wrong type or if they are not provided.

A good provider system needs to fulfill the following criteria:

  1. One should be able to get information from the provider at any level of the application.
  2. The machinery to push provided values down through an application should be minimalistic, boring and consistent.
  3. One should be able to create ad hoc providers anywhere in an application, compose providers, and modify their values on the fly.
  4. One should be able to mute providers: that is, turn them off for downstream consumers that do not need them. React doesn't do this, but I think it is essential: we don't want third-party components sneakily introspecting our providers!
  5. One should be able to interact with the provider, for example to trigger the refreshing of a token if needed.

A minimal provider application

Here's an application that fulfills all four criteria. It's written using PureScript Deku, a small library I created to experiment with event-based architectures in the DOM. The application provisions two tokens, propagating them down through the DOM using providers. It also provides mechanisms to refresh and invalidate our tokens, sending us back to a log-in page.

I'll copy-and-paste the application below, and you can (and should!) try it out via the Try it here link. Once you play with it and see from interaction how it caches, refreshes, and invalidates tokens, you'll be in a good spot to read the rest of the article.

data TokenState = Valid | Refreshed Number | Invalid

topMatter :: forall l p. Element l p
topMatter = D.div_
  [ D.p_
      [ text_
          "In the example below, when you click on the buttons needing a token, one of three things will happen. It will either:"
      ]
  , D.ul_
      ( map (D.li_ <<< text_)
          [ "keep the same token;", "get a new token; or", "log you out." ]
      )
  , D.p_
      [ text_
          "This is how real-world apps work: based on user interaction, you periodically need to get a refresh token and, if the token is not valid, log the person out."
      ]
  ]

cell0 :: forall l p. _ -> Element l p
cell0 = pure (D.td_ $ text_ "I'm just a cell...")

cell1 :: forall l p. { myBool :: Boolean | _ } -> Element l p
cell1 = do
  { myBool } <- ask
  pure $ D.td_ $ text_
    ("Here's a boolean from an internal provider: " <> show myBool)

cell2 :: forall l p. { token1 :: Event Number | _ } -> Element l p
cell2 = do
  t1 <- asks _.token1
  pure (D.td_ $ text (("Token 1: " <> _) <<< show <$> t1))

cell3 :: forall l p. { push1 :: Effect Unit | _ } -> Element l p
cell3 = do
  { push1 } <- ask
  pure
    ( D.td_ $ D.button (bang (D.OnClick := push1))
        (text_ "Do something needing token 1")
    )

cell4 :: forall l p. { token2 :: Event Number | _ } -> Element l p
cell4 = do
  t2 <- asks _.token2
  pure (D.td_ $ text (("Token 2: " <> _) <<< show <$> t2))

cell5 :: forall l p. Effect Unit -> Element l p
cell5 = do
  push2 <- ask
  pure
    ( D.td_ $ D.button (bang (D.OnClick := push2))
        (text_ "Do something needing token 2")
    )

incrementToken
  :: forall l p. Event ({ token1 :: Event Number | _ } -> Domable l p)
incrementToken = bus \setN n -> do
  t1 <- asks _.token1
  let t1n = t1 <|> n
  pure $ plant $ D.div_
    [ D.p_ $ text_ "Increment token 1 locally, leaving it unchanged elsewhere."
    , D.button (map (attr D.OnClick <<< setN <<< add 1.0) t1n) (text_ "Increment")
    , D.p_ [ text_ "Token: ", text (map show t1n) ]
    ]

authorized :: forall l p. _ -> Element l p
authorized = do
  c0 <- cell0
  c1 <- lcmap (union { myBool: true }) cell1
  c2 <- cell2
  c3 <- cell3
  c4 <- cell4
  c5 <- lcmap _.push2 cell5
  incTok <- distribute incrementToken
  pure $ D.div_
    [ topMatter
    , D.table_
        [ D.tr_
            [ D.th_ (text_ "Column 1")
            , D.th_ (text_ "Column 2")
            ]
        , D.tr_ [ c0, c1 ]
        , D.tr_ [ c2, c3 ]
        , D.tr_ [ c4, c5 ]
        ]
    , D.div_ incTok
    ]

unauthorized
  :: forall l p
   . { token1 :: Number -> Effect Unit
     , token2 :: Number -> Effect Unit
     | _
     }
  -> Element l p
unauthorized = do
  { token1, token2 } <- ask
  pure $ D.button
    ( bang $ D.OnClick := do
        getToken >>= token1
        getToken >>= token2
    )
    (text_ "Log in")

type Tokens = V (token1 :: Maybe Number, token2 :: Maybe Number)

keepRefreshOrInvalidate
  :: (Maybe Number -> Effect Unit) -> Effect Unit
keepRefreshOrInvalidate push = do
  tk <- tokenState
  case tk of
    Valid -> mempty
    Refreshed i -> push $ Just i
    Invalid -> push Nothing

main :: Effect Unit
main = runInBody1
  ( vbus (Proxy :: _ Tokens) \push event -> do
      let
        tokens = biSampleOn (bang Nothing <|> event.token2)
          ({ token1: _, token2: _ } <$> (bang Nothing <|> event.token1))
      tokens # switcher \{ token1: t1, token2: t2 } -> case t1, t2 of
        Just t1', Just t2' -> do
          let token1 = compact event.token1 <|> bang t1'
          let token2 = compact event.token2 <|> bang t2'
          let push1 = keepRefreshOrInvalidate push.token1
          let push2 = keepRefreshOrInvalidate push.token2
          authorized { token1, token2, push1, push2 }
        _, _ -> unauthorized
          { token1: lcmap Just push.token1, token2: lcmap Just push.token2 }
  )
Enter fullscreen mode Exit fullscreen mode

What's in a provider?

Providers provide, and nothing provides better than a function. In the example above, our provider is just a function. If you look at the signature for authorized, unauthorized, cell0, cell1, etc, you'll see that they're all functions with the exception of incrementToken (we'll get to this later). Importantly, all of them use the underscore _ in their argument.

I'll get to what the underscore means when I talk about metapolymorphism, but for now, you can think of it as a wild card for a type. Components that need nothing from a provider use _ as the the argument, and components that need something from a provider declare what they need and use _ for the rest.

Getting values from a provider

I said that providers are functions, but looking at all of the examples, they don't look like functions.

Let's take a closer look at cell4:

cell4 :: forall l p. { token2 :: Event Number | _ } -> Element l p
cell4 = do
  t2 <- asks _.token2
  pure (D.td_ $ text (("Token 2: " <> _) <<< show <$> t2))
Enter fullscreen mode Exit fullscreen mode

It leverages the fact that Function a implements MonadAsk to use asks (which gets the input to a function) and also implements Applicative), which creates a pure function that ignores its argument. We could have also written cell4 as:

cell4 :: forall l p. { token2 :: Event Number | _ } -> Element l p
cell4 { token2 } =
  D.td_ $ text (("Token 2: " <> _) <<< show <$> token2)
Enter fullscreen mode Exit fullscreen mode

However, to remain consistent in how the app looks and feels, everything uses do notation, and in many places, do notation is by far the most ergonomic way to work with our functions, aka providers.

If we look at authorized, we'll see that it never uses ask, and yet it still passes the provided value down to the children cell0, cell1 etc without any extra machinery:

authorized :: forall l p. _ -> Element l p
authorized = do
  c0 <- cell0
  c1 <- lcmap (union { myBool: true }) cell1
  c2 <- cell2
  c3 <- cell3
  c4 <- cell4
  c5 <- cell5
  incTok <- distribute incrementToken
  pure $ D.div_
    [ topMatter
    , D.table_
        [ D.tr_
            [ D.th_ (text_ "Column 1")
            , D.th_ (text_ "Column 2")
            ]
        , D.tr_ [ c0, c1 ]
        , D.tr_ [ c2, c3 ]
        , D.tr_ [ c4, c5 ]
        ]
    , D.div_ incTok
    ]
Enter fullscreen mode Exit fullscreen mode

This is because Function a implements Apply, Applicative, and Bind, which allows us to work with it as a monad using do notation and get its argument passed down throughout an application for free.

Ad hoc modifications to providers

In the authorized function above, we see a special modification being done for cell1. Let's look at cell1's signature:

cell1 :: forall l p. { myBool :: Boolean | _ } -> Element l p
cell1 = do
  { myBool } <- ask
  pure $ D.td_ $ text_
    ("Here's a boolean from an internal provider: " <> show myBool)
Enter fullscreen mode Exit fullscreen mode

The provider for cell1 takes a field myBool that is not provided by the top-level provider. To rectify this, we can modify our provider to add a myBool, or in other words, create an ad hoc internal provider.

  c1 <- lcmap (union { myBool: true }) cell1
Enter fullscreen mode Exit fullscreen mode

We can use the same method to mute fields from a provider. Looking at the signature of cell5, we see that it only takes a pusher.

cell5 :: forall l p. Effect Unit -> Element l p
cell5 = do
  push2 <- ask
  pure
    ( D.td_ $ D.button (bang (D.OnClick := push2))
        (text_ "Do something needing token 2")
    )
Enter fullscreen mode Exit fullscreen mode

To "mute" the other arguments of the provider, we can also use lcmap.

  c5 <- lcmap _.push2 cell5
Enter fullscreen mode Exit fullscreen mode

Pushing a provider down the DOM

So far, we've seen that the monadic bind operation and do notation will push a provider, aka Function a, down the DOM without any additional machinery.

In the world of vertical events, however, this becomes problematic. Vertical events is a metaphor I use in Horizontal and Vertical Events for things of type Event (Event (... n)), where many events are deeply nested. This can include Free Event n, Nu Event and Mu Event. In this case, our internal component may use a provider syntax, but it may be in a vertical event, for example Event (_ -> Element l p).

In this case, we can use a neat property of functions: they are distributive. In PureScript and Haskell, things following the distributive laws implement the Distributive typeclass. Amongst other things, it gives us a function:

distribute :: forall a g. Functor g => g (f a) -> f (g a)
Enter fullscreen mode Exit fullscreen mode

Here, our g is Event and our f is Function a. distribute will allow us to pull a provider to the outside of an event.

This is how the function incrementToken works. incrementToken creates an internal event bus, which is a common pattern in Deku to create internal logics for components.

incrementToken
  :: forall l p. Event ({ token1 :: Event Number | _ } -> Domable l p)
incrementToken = bus \setN n -> do
  t1 <- asks _.token1
  let t1n = t1 <|> n
  pure $ plant $ D.div_
    [ D.p_ $ text_ "Increment token 1 locally, leaving it unchanged elsewhere."
    , D.button (map (attr D.OnClick <<< setN <<< add 1.0) t1n) (text_ "Increment")
    , D.p_ [ text_ "Token: ", text (map show t1n) ]
    ]
Enter fullscreen mode Exit fullscreen mode

The provider is on the inside of the bus, so it looks and feels like all of our other components with the do notation, but the bus is in the way. Have no fear! We use distribute in authorized to make incrementToken behave like any other component in our provider-based setup.

  incTok <- distribute incrementToken
Enter fullscreen mode Exit fullscreen mode

Interacting with providers in nested components

Providers can provide anything, but realistically, they'll often provide some sort of event to respond to as well as a means to modify that event. Providers can provide their own means of modification. For example, the function keepRefreshOrInvalidate creates a closure of type Effect Unit that we can call to get a new token and log us out if need be. By attaching it to the provider, we fish it out only where we need it, like we do in cell3:

cell3 :: forall l p. { push1 :: Effect Unit | _ } -> Element l p
cell3 = do
  { push1 } <- ask
  pure
    ( D.td_ $ D.button (bang (D.OnClick := push1))
        (text_ "Do something needing token 1")
    )
Enter fullscreen mode Exit fullscreen mode

In the example above, cell3 is used deep down in the DOM to potentially log us out of our application if our token is expired.

Metapolymorphism for the win

We haven't yet explored the underscore _ in our application, but it is ubiquitous, so it's time to unpack it. To appreciate what it's doing, let's first take a tour down broken application lane by provoking a compile-time error.

Let's try to get a foo in cell2.

cell2 :: forall l p. { foo :: Unit, token1 :: Event Number | _ } -> Element l p
cell2 = do
  t1 <- asks _.token1
  t2 <- asks _.foo
  pure (D.td_ $ text (("Token 1: " <> _) <<< show <$> t1))
Enter fullscreen mode Exit fullscreen mode

The error message couldn't be clearer!

Image description

Everything else compiles fine (it's not cell2's fault for asking for a foo), but at the toplevel, we are made aware that we failed to provide a foo.

What we've done is used the wildcard in PureScript, aka _, to ask the compiler to solve what the provider needs to be. I call this metapolymorphism instead of polymorphism because we are being polymorphic in the meta-language. What the heck does that mean? To do that, let's answer the following two questions:

  • What is polymorphism?
  • What is a programming language's meta-language?

Once these two questions are answered, we'll see that metapolymorphism is using polymorphism in the meta-language.

What is polymorphism?

Polymorphism is a technique where, instead of providing a concrete type, we can provide a type variable that represents any type. Here's an example:

giveMeAnInt :: forall r. r -> Int
giveMeAnInt _ = 42

a = giveMeAnInt unit
b = giveMeAnInt 1.0
c = giveMeAnInt true
Enter fullscreen mode Exit fullscreen mode

So far so good. Now, you may be thinking "Why didn't we just do this with our providers? For example, authorized can take anything, so why not use plain ol' polymorphism instead of getting all meta?" Let's try!

Image description

Uh oh. The compiler sees that we have an r, which could be anything, but we don't need it to be anything! Specifically, we need it to have push1, token1 and a bunch of other stuff. So it's polymorphic nature, while OK for consumers of authorized, does not help with the bits of DOM that find themselves within authorized. So we're in a bind. Or are we?

What is a meta-language?

A meta-language is the language used to talk about a language. The "language" of PureScript is comprised of things like types (Element), type variables (l or p), functions (+) and quantifiers (forall). They are the tools we can use to construct a valid program, and the type-checker is checking these things for correctness. Combined together, they create the objects and morphisms in the category Purs, which is comprised of the objects (types) and morphisms (functions) that create our program.

However, we don't just use the language when we're writing a PureScript program. In fact, we can't. We need the meta-language to tell the compiler certain things about how we're using the language. Some of these tidbits, like . after a forall, are meant to disambiguate our intention to the compiler. Others, like import statements, enrich the language by pulling new objects and morphisms into the scope of our program.

The type-level wildcard in PureScript, aka _, is part of the meta-language. It is not an object in Purs. Rather, it is an instruction to the compiler to figure out what the object will eventually be. When our program compiles, the compiler will solve the most general type that can occupy _. This type is part of the language.

For example, the following is solved as Int:

Image description

The following is solved as a record with a field x that is an Int and other wise can contain anything:

Image description

And the following can be anything:

Image description

Another way to think about it is that the _ is polymorphic in the meta-langauge. It can morph into anything in the meta-language, as we just saw in the examples above, but in the language (the program as understood by the compiler) it is only ever one thing.

When working with providers, we use this technique to our advantage. We don't know what _ is, nor do we care, at different internal levels of our provider scheme. It's just a placeholder for a type that will be filled in later. So long as we write a program that compiles, the compiler will fill it in. But crucially, if and when the program doesn't compile, the compiler will give us helpful hints about what we're missing, as we saw when we tried to consume a value of foo :: Unit that was not provided.

I'm a big fan of this technique because it is super-lightweight, has crystal-clear error messages, and allows you to build up tidbits of a provider-based application with a relatively terse and straightforward syntax.

Conclusion

I hesitated to use the word "metapolymorphic" when writing this article because it sounds as pretentious as it is, but that's what's going on here: by using polymorphism in the meta-language, we can create a an expressive, ergonomic and powerful provider system in a Deku app. Even if you're not using Deku yet (and as I wrote it a few weeks ago you're likely not...yet...) you can use this technique to your advantage to create provider-based architectures in your own applications. Happy providing!

Top comments (0)