Couple of ways you could approach this, but one (fairly) simple way is to make a new type AuthorizedUser
and then have any page that requires a user to be signed in can take AuthorizedUser
instead of User
.
The key is constructing that type in a way that doesn’t allow you to construct AuthorizedUser
except after they pass whatever authentication system you have. You could do something like this:
module AuthorizedUser: {
type t = private User.t
// This is the ONLY way to create an AuthorizedUser.t.
let authenticate: string => option<t>
} = {
type t = User.t
// Silly stand in for your real auth service. Returns None if auth "fails".
let runAuthService = name =>
if name == "Ryan" {
Some({User.id: "abc123", displayName: "Ryan"})
} else {
None
}
// Returns an Some(AuthenticatedUser.t) if auth succeeds, None otherwise.
let authenticate = name => {
Belt.Option.map(runAuthService(name), user => user)
}
}
Now if you ever manage to get a AuthorizedUser.t
you know it will be a good, logged-in user.
So then you can change your Storybook
to something like this:
module Storybook = {
// Storybook can only be created by passing in an AuthorizedUser.
@react.component
let make = (~user: AuthorizedUser.t) => {
// This is "type coercion" so we can treat the user as a User.t.
let user = (user :> User.t)
<div> <h2> {React.string("Storybook")} </h2> <AccountDetails user /> </div>
}
}
Storybook
doesn’t need to deal with whether a user is logged in or not, because you can only create it with an authenticated user.
Here is a silly app to show how you might put it all together.
module User = {
type t = {id: string, displayName: string}
let getDisplayName = i => i.displayName
let getUserId = i => i.id
}
module AuthorizedUser: {
type t = private User.t
// This is the ONLY way to create an AuthorizedUser.t.
let authenticate: string => option<t>
} = {
type t = User.t
// Silly stand in for your real auth service. Returns None if auth "fails".
let runAuthService = name =>
if name == "Ryan" {
Some({User.id: "abc123", displayName: "Ryan"})
} else {
None
}
// Returns an Some(AuthenticatedUser.t) if auth succeeds, None otherwise.
let authenticate = name => {
Belt.Option.map(runAuthService(name), user => user)
}
}
module AccountDetails = {
@react.component
let make = (~user) => <div> {`Hello ${user->User.getDisplayName}!`->React.string} </div>
}
module Storybook = {
// Storybook can only be created by passing in an AuthorizedUser.
@react.component
let make = (~user: AuthorizedUser.t) => {
// This is "type coercion" so we can treat the user as a User.t.
let user = (user :> User.t)
<div> <h2> {React.string("Storybook")} </h2> <AccountDetails user /> </div>
}
}
module State = {
// Your app would probably have a record here with more stuff to track.
// For this though, we just have either an authorized user, or a guest.
type t = SignedIn(AuthorizedUser.t) | Guest
}
// A little text input where you type your name and try to log in.
module SignIn = {
@react.component
let make = (~setUser) => {
let onBlur = event => {
let name = ReactEvent.Focus.target(event)["value"]
switch AuthorizedUser.authenticate(name) {
| Some(user) => setUser(_ => State.SignedIn(user))
| None => {
let _ = %raw(`alert("Login failed!")`)
setUser(_ => State.Guest)
}
}
}
<div>
<h2> {React.string("Sign in!")} </h2>
<label> {React.string("Name")} <input type_="text" onBlur /> </label>
</div>
}
}
// Sign out by changing the app state back to Guest.
module SignOut = {
@react.component
let make = (~setUser) => {
let onClick = _event => {
setUser(_ => State.Guest)
}
<div> <button onClick> {React.string("Sign out!")} </button> </div>
}
}
module App = {
// Helper to make a storybook from an AuthorizedUser.t
let storybook = (user, setUser) => {
<div> <Storybook user /> <hr /> <SignOut setUser /> </div>
}
@react.component
let make = () => {
let defaultState = State.Guest
let (user, setUser) = React.useState(() => defaultState)
let element = switch user {
| SignedIn(user) => storybook(user, setUser)
| Guest => <SignIn setUser />
}
<div> element </div>
}
}
// Render the app.
switch ReactDOM.querySelector("#app") {
| Some(root) => ReactDOM.render(<React.StrictMode> <App /> </React.StrictMode>, root)
| None => Js.log("No app element!")
}
Of course, your real app would be a lot fancier than this (and actually do something real)…but it’s just an example of one way to get the type system to enforce some stuff for you. And if you need some more complicated stuff for users like basic user, admin, guest, whatever, you can always switch to a GADT (generalized algebraic data type) if you need it.