skip to content
Cover Image

Type-Safe States Are Your Friend

I recently worked on two separate issues and solved them using type-safe states, which got me thinking. What are the benefits, and why might we need them?

Usually, we start building logic from the ground up, using primitives. As long as the function names clearly indicate what we want to achieve, primitives are okay to use. But as logic complexity increases, continuing to use primitives might become an issue.

Primitive Obsession is a code smell that emphasizes this problem. But as with almost all design decisions, context matters. Using objects for every task is the other side of the coin. When should we use objects instead of primitive types? It depends :)

Let’s explore this concept using two examples.

Extending Current Logic

We have a Composable, and we want to show a different version based on a boolean flag. Let’s call this flag isEnabled:

// Simplified code
@Composable
fun someContent(state: State) {
  if (state.isEnabled) {
    EnabledContent()
  } else {
    DisabledContent()
  }
}

The problem was that there was another flag that we needed to check. Enabled content should be shown when it is also eligible. So, the new logic for visibility is this:

  • EnabledContent = isEnabled && isEligible
  • DisabledContent = !isEnabled || !isEligible
  • No Content = isEnabled

Here, we could pass the secondary flag to the composable, adjust our conditionals, and call it a day. While fast, that approach leaks part of the logic to the UI layer. Also, it is harder to write tests and verify our logic. The combination of the flags is still processed inside the Composable, which we don’t want.

Instead, we could define a new type to define the kind of content we want to display.

sealed interface ContentKind {
  data object EnabledEligible: ContentKind
  data object Disabled: ContentKind
  data object NoContent: ContentKind
}

Now, we can write a function (or defined as a val) inside the State that would output the correct content kind, and we can easily test it:

data class State(
  private val isEnabled: Boolean,
  private val isEligible: Boolean,
) {
  fun getContentKind(): ContentKind = when {
    isEnabled && isEligible -> EnabledEligible
    isEnabled -> NoContent
    else -> Disabled
  }
}

And we can update our composable to reflect that state changes:

// Simplified code
@Composable
fun someContent(state: State) {
  when(state.getContentKind()) {
    EnabledEligible -> EnabledContent()
    Disabled -> DisabledContent()
    NoContent -> {}
  }
}

Making Implementation Easier to Reason With

Defining type-safe states might make your implementation easier to reason with, as well as make tests easier and keep the UI logic where it belongs.

Let’s imagine a Tracker class used by a RecyclerView that lets us define triggers when a certain component becomes visible on the screen. We use this class from the adapter of the RecyclerView with a trackVisibility function.

// Simplified code
adapter.trackVisibility {
  // Trigger only once
  once {
    onVisible = {}
  }
  // Trigger multiple times
  multipleTimes {
    onVisible = {}
  }
}

Under the hood, a defined listener is added to the list, and according to its type (once or multipleTimes), we try to trigger it and remove or keep it in the listener’s list based on its condition result.

Under that hood, we check the component’s visibility, and based on that, we decide whether to trigger it.

// Simplified code
fun TrackingType.shouldTrigger(view: View): Boolean {
  return view.isVisibilityConditionSatisfied()
}

Our task is to add a third trigger type called continuous. This trigger should be active as long as the screen is visible, trigger an onVisible callback when the component becomes visible, and trigger an onHidden callback when the component becomes hidden.

We use booleans to determine whether we should trigger and whether the component is visible or not, but we need more information.

Now we have an additional trigger condition when the view becomes hidden. Also, the view we want to check might not be on the screen. So, it should inform us when it actually calculated the visibility of the component, and it is invisible, and when it couldn’t find the component, and it is false.

Let’s define types to understand the rules we need to follow.

sealed interface TriggerType {
  data object NoTrigger(): TriggerType
  data object VisibleTrigger(): TriggerType
  data object HiddenTrigger(): TriggerType

  fun shouldTrigger() = this != NO_TRIGGER
}

sealed interface VisibilityCalculationResult {
  data object NotCalculated(): VisibilityCalculationResult
  data object CalculatedTrue(): VisibilityCalculationResult
  data object CalculatedFalse(): VisibilityCalculationResult
}

Defining types like these helps us understand what the code needs to do better. We could achieve the same thing by using primitive types, but using type-safe states lets us control the probability outcome.

We would need at least two flags to get the same option set. And we would need to build their mapping logic. Here, we are defining the computation steps in our logic execution.

Here is the updated high-level logic for continuous trigger:


fun View.isVisibilityConditionSatisfied(): VisibilityCalculationResult {
  // Logic implementation
}

fun TrackingType.calculateTriggerType(view: View): TriggerType {
  val result = view.isVisibililtyConditionSatisfied()
  when {
    TrackingType.continuous && result == CalculatedTrue() -> VisibleTrigger()
    TrackingType.continuous && result == CalculatedFalse() -> HiddenTrigger()
	result == CalculatedTrue() -> VisibleTrigger()
	else -> NoTrigger()
  }
}

Using TriggerType, we can run either onVisible or onHidden callbacks and continue to keep the listener in the list if the type is NoTrigger.

Conclusion

I think we need to analyze the problem at hand before using type-safe states like these. As we said in the beginning, context matters. Additional benefits I observe are:

  • Function and value namings are more consistent and easy to reason because we use domain-specific types.
  • States can contain localized logic inside, helping to encapsulate repetitive logic.

It might not be the best solution when the code’s performance is really important, but using them in a balanced way may certainly improve the codebase in the long run.


Cover Image: “Photo by Alain Pham on Unsplash