How to handle optional fields in PureScript

zelenya

Zelenya

Posted on July 14, 2023

How to handle optional fields in PureScript

📹 Hate reading articles? Check out the complementary video, which covers the same content.


This tutorial shows how to handle optional fields using the undefined-is-not-a-problem library.

Why?

An ergonomic way of working with optional record fields is especially useful when interacting with JavaScript (aka FFI).

Imagine we want to integrate react-player into our app. The docs show that we can create a basic react player component by passing a url:

<ReactPlayer url='https://www.youtube.com/watch?v=ysz5S6PUM-U'/>
Enter fullscreen mode Exit fullscreen mode

But then, if we scroll down to the Props (properties) section, we see many other properties, which also have default values.

In the wild-west JavaScript, we don’t have to worry about any of these – we pass values we care about and ignore the rest. For example, we can set light to true:

<ReactPlayer url='https://www.youtube.com/watch?v=ysz5S6PUM-U' light=true/>
Enter fullscreen mode Exit fullscreen mode

đź’ˇ The light prop will render a video thumbnail with a simple play icon and only load the full player once a user has interacted with the image.

But what about the PureScript world – where we must be strict about types?

Optional values in PureScript

First, we can also ignore all the fields:

type ReactPlayerProps = { url :: String }
Enter fullscreen mode Exit fullscreen mode

This is a fine type, which we can use to interact with the react-player library and create player components with any url, while everything else stays the default.

props :: ReactPlayerProps
props = { url: "https://youtu.be/tIUXB0TrlpU" }
Enter fullscreen mode Exit fullscreen mode

What if we want to have an option to set the light property?

💡 Note that there is no null, undefined, or anything like that in the PureScript standard library.

If you have an FP background, you might reach for option type or maybe type.

type ReactPlayerProps = { url :: String, light :: Maybe Boolean }
Enter fullscreen mode Exit fullscreen mode

But this doesn’t come for free: it has a runtime cost (it’s not a js primitive) and a developer cost (you can’t omit these properties – you have to pass Nothing explicitly), which is annoying, especially for large records.

prop1 :: ReactPlayerProps
prop1 = { url: "...", light: Nothing }

prop2 :: ReactPlayerProps
prop2 = { url: "...", light: Just true }
Enter fullscreen mode Exit fullscreen mode

Luckily there is undefined-is-not-a-problem.

Undefined is not a problem

The undefined-is-not-a-problem library unlocks a neat way to handle optional record fields using undefined | a values and typesafe zero-cost coercion.

💡 undefined | a is an untagged union – the value is either undefined or has type a.

(Ignore unless you’re following at home)

JS dependencies: react, react-dom, react-player.

PureScript dependencies: react-basic-dom, react-basic-hooks, undefined-is-not-a-problem, and whatever they bring.

JS file:

// Problems.js
export { default as reactPlayerImpl } from 'react-player';
Enter fullscreen mode Exit fullscreen mode

PureScript file, imports:

-- Problems.purs
module Problems where

import Data.Undefined.NoProblem (Opt, opt)
import Data.Undefined.NoProblem.Closed (class Coerce, coerce)
import React.Basic.Hooks (JSX, ReactComponent, element)
Enter fullscreen mode Exit fullscreen mode

Zero cost coercion

The library provides Opt exactly for our use cases:

type ReactPlayerProps = { url :: String, light :: Opt Boolean }
Enter fullscreen mode Exit fullscreen mode

Optional field value (Opt a) is just a value, and there are two ways to create it: undefined or opt.

example1 :: ReactPlayerProps
example1 = { url: "...", light: undefined }

example2 :: ReactPlayerProps
example2 = { url: "...", light: opt true }
Enter fullscreen mode Exit fullscreen mode

undefined is JavaScript’s undefined, and opt calls PureScript’s coerce function. This means we don’t pay for these. There is no wrapping or unwrapping. Primitive, plain JavaScript.

However, we still explicitly pass undefined and call opt by hand, which could be better. Let’s deal with it next.

Boilerplate coercion

This is a typical FFI boilerplate that I use:

type ReactPlayerProps = { url :: String, light :: Opt Boolean }

foreign import reactPlayerImpl :: ReactComponent ReactPlayerProps

-- [3]                   [4]                            
reactPlayer :: forall p. Coerce p ReactPlayerProps => p -> JSX
reactPlayer props = element reactPlayerImpl (coerce props) -- [5]
Enter fullscreen mode Exit fullscreen mode
  • Create a type for the component props with required field(s) and optional field(s) that matter.
  • Import foreign component using the props type and call it something suffixed with Impl.
  • Create a function to construct the component – it takes props and returns a JSX (rendered React VDOM).

Let’s see how it can be used and then how it works.

player1 :: JSX
player1 = reactPlayer { url: "https://youtu.be/tIUXB0TrlpU" }

player2 :: JSX
player2 = reactPlayer { url: "https://youtu.be/tIUXB0TrlpU", light: false }
Enter fullscreen mode Exit fullscreen mode

No boilerplate! We can ignore optional fields and use straightforward types to set them (no opt, no Just, etc.). Note that required fields are still required!

player3 = reactPlayer {}
-- Compilation error: Missing required field: url
Enter fullscreen mode Exit fullscreen mode

How it works

Here is the component constructor once again:

--                       [2]
reactPlayer :: forall p. Coerce p ReactPlayerProps => p -> JSX
reactPlayer props = element reactPlayerImpl (coerce props) -- [1]
Enter fullscreen mode Exit fullscreen mode

[1] coerce fills the missing fields in a given record and transforms values to Opt if needed. This happens on the type level. [2] The Coerce constraint is to check/prove if it’s safe to coerce.

💡 Note the imports we’re using:

import Data.Undefined.NoProblem.Closed (class Coerce, coerce)

The library also provides two coercing strategies: Closed and Open. They share the interface but differ in instance chains, which results in slightly different behaviors. Check out the docs when/if you’re curious.

And that’s the core of it.

[Bonus] Additional type-safety

There are cases when we want even more type-safety.

For example, I’m not a fan of booleans, so I might create a proper type to use in PureScript:

data Mode = Light | Full

toBoolean :: Mode -> Boolean
toBoolean = case _ of 
 Light -> true
 Full -> false
Enter fullscreen mode Exit fullscreen mode

And adopt the props type:

type ReactPlayerProps = { url :: String, light :: Opt Mode }
Enter fullscreen mode Exit fullscreen mode

Good for me, but the JavaScript library still expects a boolean. We must ffi the old props with a boolean:

foreign import reactPlayerImpl :: ReactComponent { url :: String, light :: Opt Boolean }
Enter fullscreen mode Exit fullscreen mode

We can glue the two by adopting the constructor function.

import Data.Undefined.NoProblem (pseudoMap)
Enter fullscreen mode Exit fullscreen mode
reactPlayer :: forall p. Coerce p ReactPlayerProps => p -> JSX
reactPlayer props = 
  element reactPlayerImpl 
    $ modify (Proxy :: _ "light") (pseudoMap toBoolean) -- [2]
    $ coerce props                                      -- [1]
Enter fullscreen mode Exit fullscreen mode

[1] We coerce all the props as we did before and then [2] map the light value to its boolean representation using pseudoMap.

💡 We use the Record module to modify a property for a label specified using a value-level proxy for a type-level string.

Footer

With a little prep work, we get a nice way of working with optional values. Outside the component module, we use straightforward types and don’t worry about unnecessary optional fields.



📹 Hate reading articles? Check out the complementary video, which covers the same content.


This tutorial shows how to handle optional fields using the undefined-is-not-a-problem library.

Why?

An ergonomic way of working with optional record fields is especially useful when interacting with JavaScript (aka FFI).

Imagine we want to integrate react-player into our app. The docs show that we can create a basic react player component by passing a url:

<ReactPlayer url='https://www.youtube.com/watch?v=ysz5S6PUM-U'/>
Enter fullscreen mode Exit fullscreen mode

But then, if we scroll down to the Props (properties) section, we see many other properties, which also have default values. Here is a preview:

Screenshot 2023-07-11 at 17.28.24.png

In the wild-west JavaScript, we don’t have to worry about any of these – we pass values we care about and ignore the rest. For example, we can set light to true:

<ReactPlayer url='https://www.youtube.com/watch?v=ysz5S6PUM-U' light=true/>
Enter fullscreen mode Exit fullscreen mode

đź’ˇ The light prop will render a video thumbnail with a simple play icon and only load the full player once a user has interacted with the image.

But what about the PureScript world – where we must be strict about types?

Optional values in PureScript

First, we can also ignore all the fields:

type ReactPlayerProps = { url :: String }
Enter fullscreen mode Exit fullscreen mode

This is a fine type, which we can use to interact with the react-player library and create player components with any url, while everything else stays the default.

props :: ReactPlayerProps
props = { url: "https://youtu.be/tIUXB0TrlpU" }
Enter fullscreen mode Exit fullscreen mode

What if we want to have an option to set the light property?

💡 Note that there is no null, undefined, or anything like that in the PureScript standard library.

If you have an FP background, you might reach for option type or maybe type.

type ReactPlayerProps = { url :: String, light :: Maybe Boolean }
Enter fullscreen mode Exit fullscreen mode

But this doesn’t come for free: it has a runtime cost (it’s not a js primitive) and a developer cost (you can’t omit these properties – you have to pass Nothing explicitly), which is annoying, especially for large records.

prop1 :: ReactPlayerProps
prop1 = { url: "...", light: Nothing }

prop2 :: ReactPlayerProps
prop2 = { url: "...", light: Just true }
Enter fullscreen mode Exit fullscreen mode

Luckily there is undefined-is-not-a-problem.

Undefined is not a problem

The undefined-is-not-a-problem library unlocks a neat way to handle optional record fields using undefined | a values and typesafe zero-cost coercion.

💡 undefined | a is an untagged union – the value is either undefined or has type a.

(Ignore unless you’re following at home)

JS dependencies: react, react-dom, react-player.

PureScript dependencies: react-basic-dom, react-basic-hooks, undefined-is-not-a-problem, and whatever they bring.

JS file:

// Problems.js
export { default as reactPlayerImpl } from 'react-player';
Enter fullscreen mode Exit fullscreen mode

PureScript file, imports:

-- Problems.purs
module Problems where

import Data.Undefined.NoProblem (Opt, opt)
import Data.Undefined.NoProblem.Closed (class Coerce, coerce)
import React.Basic.Hooks (JSX, ReactComponent, element)
Enter fullscreen mode Exit fullscreen mode

Zero cost coercion

The library provides Opt exactly for our use cases:

type ReactPlayerProps = { url :: String, light :: Opt Boolean }
Enter fullscreen mode Exit fullscreen mode

Optional field value (Opt a) is just a value, and there are two ways to create it: undefined or opt.

example1 :: ReactPlayerProps
example1 = { url: "...", light: undefined }

example2 :: ReactPlayerProps
example2 = { url: "...", light: opt true }
Enter fullscreen mode Exit fullscreen mode

undefined is JavaScript’s undefined, and opt calls PureScript’s coerce function. This means we don’t pay for these. There is no wrapping or unwrapping. Primitive, plain JavaScript.

However, we still explicitly pass undefined and call opt by hand, which could be better. Let’s deal with it next.

Boilerplate coercion

This is a typical FFI boilerplate that I use:

type ReactPlayerProps = { url :: String, light :: Opt Boolean }

foreign import reactPlayerImpl :: ReactComponent ReactPlayerProps

-- [3]                   [4]                            
reactPlayer :: forall p. Coerce p ReactPlayerProps => p -> JSX
reactPlayer props = element reactPlayerImpl (coerce props) -- [5]
Enter fullscreen mode Exit fullscreen mode
  • Create a type for the component props with required field(s) and optional field(s) that matter.
  • Import foreign component using the props type and call it something suffixed with Impl.
  • Create a function to construct the component – it takes props and returns a JSX (rendered React VDOM).

Let’s see how it can be used and then how it works.

player1 :: JSX
player1 = reactPlayer { url: "https://youtu.be/tIUXB0TrlpU" }

player2 :: JSX
player2 = reactPlayer { url: "https://youtu.be/tIUXB0TrlpU", light: false }
Enter fullscreen mode Exit fullscreen mode

No boilerplate! We can ignore optional fields and use straightforward types to set them (no opt, no Just, etc.). Note that required fields are still required!

player3 = reactPlayer {}
-- Compilation error: Missing required field: url
Enter fullscreen mode Exit fullscreen mode

How it works

Here is the component constructor once again:

--                       [2]
reactPlayer :: forall p. Coerce p ReactPlayerProps => p -> JSX
reactPlayer props = element reactPlayerImpl (coerce props) -- [1]
Enter fullscreen mode Exit fullscreen mode

[1] coerce fills the missing fields in a given record and transforms values to Opt if needed. This happens on the type level. [2] The Coerce constraint is to check/prove if it’s safe to coerce.

💡 Note the imports we’re using:

import Data.Undefined.NoProblem.Closed (class Coerce, coerce)

The library also provides two coercing strategies: Closed and Open. They share the interface but differ in instance chains, which results in slightly different behaviors. Check out the docs when/if you’re curious.

And that’s the core of it.

[Bonus] Additional type-safety

There are cases when we want even more type-safety.

For example, I’m not a fan of booleans, so I might create a proper type to use in PureScript:

data Mode = Light | Full

toBoolean :: Mode -> Boolean
toBoolean = case _ of 
 Light -> true
 Full -> false
Enter fullscreen mode Exit fullscreen mode

And adopt the props type:

type ReactPlayerProps = { url :: String, light :: Opt Mode }
Enter fullscreen mode Exit fullscreen mode

Good for me, but the JavaScript library still expects a boolean. We must ffi the old props with a boolean:

foreign import reactPlayerImpl :: ReactComponent { url :: String, light :: Opt Boolean }
Enter fullscreen mode Exit fullscreen mode

We can glue the two by adopting the constructor function.

import Data.Undefined.NoProblem (pseudoMap)
Enter fullscreen mode Exit fullscreen mode
reactPlayer :: forall p. Coerce p ReactPlayerProps => p -> JSX
reactPlayer props = 
  element reactPlayerImpl 
    $ modify (Proxy :: _ "light") (pseudoMap toBoolean) -- [2]
    $ coerce props                                      -- [1]
Enter fullscreen mode Exit fullscreen mode

[1] We coerce all the props as we did before and then [2] map the light value to its boolean representation using pseudoMap.

💡 We use the Record module to modify a property for a label specified using a value-level proxy for a type-level string.

Footer

With a little prep work, we get a nice way of working with optional values. Outside the component module, we use straightforward types and don’t worry about unnecessary optional fields.



đź’– đź’Ş đź™… đźš©
zelenya
Zelenya

Posted on July 14, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related