---
url: /projects/utility-library.md
description: >-
  Build and test a typed shared library with results, retries, and rate
  limiting.
---

# Utility Library Project

*Written: {{ $frontmatter.date }}*

You build a typed shared library to reuse in later projects.

## Learning objectives

You define a Result type, retry fallible calls, limit call rates, format values for display, and test pure modules with asserts.

## Prerequisites

You need modules and strict basics. Read [modules and require](../luau-basics/modules-and-require.md), [annotations and inference](../strict-typing/annotations-and-inference.md), [errors and defensive code](../luau-basics/errors-and-defensive-code.md), and [testing](../tooling/testing.md).

## Specification

You create four shared modules and one Studio test script.

Module layout:

* `ReplicatedStorage/Shared/Result` handles success or failure values.
* `ReplicatedStorage/Shared/Retry` reruns a fallible function.
* `ReplicatedStorage/Shared/RateLimiter` allows or denies an action per key.
* `ReplicatedStorage/Shared/Format` formats coins and time.
* `ServerScriptService/UtilityTest` runs asserts in Play.

Acceptance:

* Each module opens with `--!strict`.
* Each module returns one table.
* Shared modules freeze their return table.
* Shared modules fetch no services.
* Test script asserts ok paths and failure paths.
* Test script prints `UtilityTest passed` when all checks hold.

The server stays authoritative when you reuse this library, even when shared code runs on both sides. See [server authority](https://create.roblox.com/docs/en-us/projects/server-authority) and [authority techniques](https://create.roblox.com/docs/en-us/projects/server-authority/techniques).

## Architecture

Shared stays pure, with effects at the edge. Result carries outcomes. Retry calls Result producers. RateLimiter guards remotes. Format shapes display strings. Test requires each module and checks behavior. No module requires another module except Test, so dependencies stay flat with no cycles. You can copy this folder into any later project.

## Implementation

Follow these steps in order.

1. Create a folder `Shared` under `ReplicatedStorage`.
2. Add `Result` as a ModuleScript under `Shared`.
3. Add `Retry` as a ModuleScript under `Shared`.
4. Add `RateLimiter` as a ModuleScript under `Shared`.
5. Add `Format` as a ModuleScript under `Shared`.
6. Add `UtilityTest` as a Script under `ServerScriptService`.
7. Press Play and read the Output window.

Container: `ReplicatedStorage/Shared/Result` as a ModuleScript. Behavior: you wrap a value or an error in one table. Expect Play to show no output from this module alone.

```luau
--!strict
export type Result<T, E> = {
	ok: boolean,
	value: T?,
	error: E?,
}

local Result = {}

function Result.ok<T>(value: T): Result<T, string>
	return { ok = true, value = value, error = nil }
end

function Result.err<E>(message: E): Result<string, E>
	return { ok = false, value = nil, error = message }
end

function Result.unwrap<T, E>(result: Result<T, E>): T
	if result.ok then
		local value: T? = result.value
		assert(value ~= nil, "missing value")
		return value :: T
	end
	local message: string = tostring(result.error)
	error(message)
end

function Result.isOk<T, E>(result: Result<T, E>): boolean
	if result.ok then
		return true
	end
	return false
end

return table.freeze(Result)
```

Prose walkthrough: you call `ok` for a value and `err` for a failure. Use `unwrap` only when you accept a throw on misuse, and `isOk` when you branch without throwing.

Container: `ReplicatedStorage/Shared/Retry` as a ModuleScript. Behavior: you run a function up to N times with a wait between tries. Expect Play to retry twice then return the third result.

```luau
--!strict
local Retry = {}

function Retry.run(operation: () -> (boolean, string), attempts: number, waitSeconds: number): (boolean, string)
	local count: number = attempts
	if count < 1 then
		count = 1
	end
	local waitTime: number = waitSeconds
	if waitTime < 0 then
		waitTime = 0
	end
	local lastError: string = "no attempts"
	local index: number = 1
	while index <= count do
		local ok: boolean, value: string = operation()
		if ok then
			return true, value
		end
		lastError = value
		if index < count and waitTime > 0 then
			task.wait(waitTime)
		end
		index += 1
	end
	return false, lastError
end

return table.freeze(Retry)
```

Prose walkthrough: you pass a function that returns true plus a value or false plus an error, with the max tries and the pause. It returns true on the first success, or false with the last error when all tries fail.

Container: `ReplicatedStorage/Shared/RateLimiter` as a ModuleScript. Behavior: you allow N actions per window per key. Expect rapid calls after the limit to return false until the window passes.

```luau
--!strict
export type Limiter = {
	limit: number,
	window: number,
	hits: { [string]: { [number]: number } },
}

local RateLimiter = {}

function RateLimiter.new(limit: number, window: number): Limiter
	local safeLimit: number = limit
	if safeLimit < 1 then
		safeLimit = 1
	end
	local safeWindow: number = window
	if safeWindow <= 0 then
		safeWindow = 1
	end
	return { limit = safeLimit, window = safeWindow, hits = {} }
end

function RateLimiter.allow(limiter: Limiter, key: string): boolean
	local now: number = os.clock()
	local bucket: { [number]: number }? = limiter.hits[key]
	if bucket == nil then
		bucket = {}
		limiter.hits[key] = bucket
	end
	local active: { [number]: number } = bucket :: { [number]: number }
	local fresh: { [number]: number } = {}
	for _, moment in active do
		if now - moment < limiter.window then
			table.insert(fresh, moment)
		end
	end
	limiter.hits[key] = fresh
	if #fresh >= limiter.limit then
		return false
	end
	table.insert(fresh, now)
	return true
end

function RateLimiter.clear(limiter: Limiter, key: string): ()
	limiter.hits[key] = nil
end

return table.freeze(RateLimiter)
```

Prose walkthrough: you create one limiter with a count and a window in seconds, then call `allow` with a player id or action name. It stores only timestamps inside the window. Clear a key when you reset a test.

Container: `ReplicatedStorage/Shared/Format` as a ModuleScript. Behavior: you turn numbers into short display strings. Expect 1500 to show as `1.5K` and 90 seconds to show as `01:30`.

```luau
--!strict
local Format = {}

function Format.coins(amount: number): string
	local value: number = amount
	if value < 0 then
		value = 0
	end
	if value >= 1_000_000 then
		return `{value // 1_000_000}.{(value // 100_000) % 10}M`
	end
	if value >= 10_000 then
		return `{value // 1_000}K`
	end
	if value >= 1_000 then
		return `{value // 1_000}.{(value // 100) % 10}K`
	end
	return tostring(math.floor(value))
end

function Format.clock(totalSeconds: number): string
	local total: number = math.floor(totalSeconds)
	if total < 0 then
		total = 0
	end
	local minutes: number = total // 60
	local seconds: number = total % 60
	local minuteText: string = tostring(minutes)
	if minutes < 10 then
		minuteText = `0{minutes}`
	end
	local secondText: string = tostring(seconds)
	if seconds < 10 then
		secondText = `0{seconds}`
	end
	return `{minuteText}:{secondText}`
end

return table.freeze(Format)
```

Prose walkthrough: call `coins` for wallet labels and `clock` for timers. Pass plain numbers to get short stable strings.

Container: `ServerScriptService/UtilityTest` as a Script. Behavior: you require all four modules and assert core paths. Expect Output to show `UtilityTest passed` on a clean run.

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Result = require(Shared:WaitForChild("Result"))
local Retry = require(Shared:WaitForChild("Retry"))
local RateLimiter = require(Shared:WaitForChild("RateLimiter"))
local Format = require(Shared:WaitForChild("Format"))

local okResult = Result.ok("coin")
assert(Result.isOk(okResult), "ok flag")
assert(Result.unwrap(okResult) == "coin", "unwrap value")

local errResult = Result.err("full")
assert(not Result.isOk(errResult), "err flag")

local tries: number = 0
local function flaky(): (boolean, string)
	tries += 1
	if tries < 3 then
		return false, "busy"
	end
	return true, "done"
end
local worked: boolean, text: string = Retry.run(flaky, 4, 0)
assert(worked, "retry success")
assert(text == "done", "retry value")
assert(tries == 3, "retry count")

local limiter = RateLimiter.new(2, 60)
assert(RateLimiter.allow(limiter, "shop"), "first allow")
assert(RateLimiter.allow(limiter, "shop"), "second allow")
assert(not RateLimiter.allow(limiter, "shop"), "third denied")
RateLimiter.clear(limiter, "shop")
assert(RateLimiter.allow(limiter, "shop"), "allow after clear")

assert(Format.coins(1500) == "1.5K", "coin format")
assert(Format.coins(42) == "42", "small coin format")
assert(Format.clock(90) == "01:30", "clock format")
print("UtilityTest passed")
```

Prose walkthrough: run this in Play only. Check Result flags first, retry counts next, limiter allow then deny then allow, and format strings last. Read the final print to confirm success.

## Testing it

1. Press Play in Studio.
2. Open View then Output.
3. Confirm `UtilityTest passed` appears once.
4. Change `RateLimiter.new(2, 60)` to `RateLimiter.new(1, 60)` and press Play again.
5. Confirm the second `allow` assert fails.
6. Restore the limit to 2.
7. Press Play again and confirm the pass message returns.

Test pure logic before you wire gameplay, and keep this test file in the project.

## Common mistakes and failures

You return two values where one table is expected. Fix the return shape. You compare booleans with `== true`. Use truthiness instead. You skip nil checks on `result.value`. Assert before you return. You store limiter state in a local that resets each call. Keep the limiter table outside the function. You format with string concatenation on numbers. Convert with `tostring` first. You require modules by dynamic paths. Use static `WaitForChild` names.

## Exercises and extensions

1. Add `Result.map` that transforms the value when ok and passes errors through.
2. Add `Retry.runResult` that returns a Result table instead of two values.
3. Add `RateLimiter.count` that returns the fresh hit count for a key.
4. Add `Format.percent` that clamps 0 to 1 and returns `42%` style text.
5. Copy this Shared folder into [gameplay mechanic](gameplay-mechanic.md) and require Result in the coin grant path.

::: details Solution 1
Add `map` beside `ok`/`err` in `Shared/Result`. It applies `fn` only when `ok` holds and passes errors through untouched.

**ModuleScript in ReplicatedStorage/Shared named ResultMap**

```luau
--!strict
export type Result<T, E> = {
	ok: boolean,
	value: T?,
	error: E?,
}

local ResultMap = {}

function ResultMap.map<T, U, E>(result: Result<T, E>, fn: (T) -> U): Result<U, E>
	if not result.ok then
		return { ok = false, value = nil, error = result.error }
	end
	local value: T? = result.value
	assert(value ~= nil, "missing value")
	return { ok = true, value = fn(value :: T), error = nil }
end

return table.freeze(ResultMap)
```

Expected behavior: mapping over ok transforms the value, while mapping over err returns the same error without calling `fn`.
:::

::: details Solution 2
Wrap `Retry.run` so callers get one table instead of two values.

**ModuleScript in ReplicatedStorage/Shared named RetryResult**

```luau
--!strict
local RetryResult = {}

function RetryResult.runResult(operation: () -> (boolean, string), attempts: number, waitSeconds: number): { ok: boolean, value: string? }
	local count: number = attempts
	if count < 1 then
		count = 1
	end
	local tries: number = 0
	local lastError: string = "no attempts"
	while tries < count do
		tries += 1
		local ok: boolean, value: string = operation()
		if ok then
			return { ok = true, value = value }
		end
		lastError = value
		if tries < count and waitSeconds > 0 then
			task.wait(waitSeconds)
		end
	end
	return { ok = false, value = lastError }
end

return table.freeze(RetryResult)
```

Expected behavior: success returns `{ ok = true }` with the value, while exhausted tries return `{ ok = false }` with the last error.
:::

::: details Solution 3
Count fresh hits without recording a new one.

**ModuleScript in ReplicatedStorage/Shared named LimiterCount**

```luau
--!strict
export type Limiter = {
	limit: number,
	window: number,
	hits: { [string]: { [number]: number } },
}

local LimiterCount = {}

function LimiterCount.count(limiter: Limiter, key: string): number
	local now: number = os.clock()
	local bucket: { [number]: number }? = limiter.hits[key]
	if bucket == nil then
		return 0
	end
	local fresh: number = 0
	for _, moment in bucket :: { [number]: number } do
		if now - moment < limiter.window then
			fresh += 1
		end
	end
	return fresh
end

return table.freeze(LimiterCount)
```

Expected behavior: `count` returns 2 after two allows inside the window and 0 for an unknown key, without changing the bucket.
:::

::: details Solution 4
Clamp first, then format with a percent sign.

**ModuleScript in ReplicatedStorage/Shared named FormatPercent**

```luau
--!strict
local FormatPercent = {}

function FormatPercent.percent(ratio: number): string
	local value: number = ratio
	if not math.isfinite(value) then
		value = 0
	end
	if value < 0 then
		value = 0
	end
	if value > 1 then
		value = 1
	end
	return `{math.floor(value * 100)}%`
end

return table.freeze(FormatPercent)
```

Expected behavior: `percent(0.42)` returns `42%`, negatives return `0%`, values above 1 return `100%`, and NaN returns `0%`.
:::

::: details Solution 5
Return a Result from the claim path so callers handle grants and rejections one way.

**Script in ServerScriptService named ClaimWithResult**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Result = require(Shared:WaitForChild("Result"))

local function grantCoin(balance: number): { ok: boolean, value: number?, error: string? }
	if not math.isfinite(balance) then
		return Result.err("bad-balance")
	end
	if balance < 0 then
		return Result.err("bad-balance")
	end
	return Result.ok(math.floor(balance) + 1)
end

local granted = grantCoin(4)
assert(Result.isOk(granted), "grant ok")
assert(Result.unwrap(granted) == 5, "grant value")
local rejected = grantCoin(0 / 0)
assert(not Result.isOk(rejected), "reject nan")
print("ClaimWithResult passed")
```

Expected behavior: Output prints `ClaimWithResult passed` because valid balances grant and NaN rejects through the same Result shape.
:::

## Summary

You built four typed helpers and tested them with asserts. Next read [gameplay mechanic](gameplay-mechanic.md).
