Function Libraries
On this page 22
Build reusable TypeScript function libraries with Stacks.
Overview
Function libraries provide reusable utilities, composables, and helpers that can be shared across projects.
Project Structure
Organize functions by category:
functions/
├── string/
│ ├── capitalize.ts
│ ├── slugify.ts
│ └── index.ts
├── array/
│ ├── chunk.ts
│ ├── unique.ts
│ └── index.ts
├── composables/
│ ├── useDebounce.ts
│ ├── useThrottle.ts
│ └── index.ts
├── validators/
│ ├── isEmail.ts
│ ├── isUrl.ts
│ └── index.ts
└── index.ts
Writing Functions
Pure Functions
Write pure, predictable functions:
// functions/string/capitalize.ts
/**
* Capitalize the first letter of a string
* @param str - The string to capitalize
* @returns The capitalized string
* @example
* capitalize('hello') // 'Hello'
* capitalize('HELLO') // 'HELLO'
*/
export function capitalize(str: string): string {
if (!str) return str
return str.charAt(0).toUpperCase() + str.slice(1)
}
/**
* Capitalize all words in a string
* @param str - The string to title case
* @returns The title cased string
* @example
* titleCase('hello world') // 'Hello World'
*/
export function titleCase(str: string): string {
return str.replace(
/\w\S*/g,
txt => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
)
}
Generic Functions
Use TypeScript generics for flexibility:
// functions/array/chunk.ts
/**
* Split an array into chunks of a specified size
* @param array - The array to split
* @param size - The size of each chunk
* @returns An array of chunks
* @example
* chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]
*/
export function chunk<T>(array: T[], size: number): T[][] {
if (size <= 0) {
throw new Error('Chunk size must be greater than 0')
}
const result: T[][] = []
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size))
}
return result
}
/**
* Get unique values from an array
* @param array - The array to deduplicate
* @param key - Optional key function for object comparison
* @returns Array with unique values
*/
export function unique<T>(array: T[], key?: (item: T) => unknown): T[] {
if (!key) {
return [...new Set(array)]
}
const seen = new Set()
return array.filter(item => {
const k = key(item)
if (seen.has(k)) return false
seen.add(k)
return true
})
}
STX Composables
Stateful Composables
Create composables with reactive state:
// functions/composables/useCounter.ts
import { computed, ref, type Ref } from '@stacksjs/stx'
export interface UseCounterOptions {
min?: number
max?: number
}
export interface UseCounterReturn {
count: Ref<number>
doubled: Readonly<Ref<number>>
increment: () => void
decrement: () => void
set: (value: number) => void
reset: () => void
}
export function useCounter(
initial = 0,
options: UseCounterOptions = {}
): UseCounterReturn {
const { min = -Infinity, max = Infinity } = options
const count = ref(initial)
const doubled = computed(() => count.value * 2)
function increment() {
if (count.value < max) {
count.value++
}
}
function decrement() {
if (count.value > min) {
count.value--
}
}
function set(value: number) {
count.value = Math.max(min, Math.min(max, value))
}
function reset() {
count.value = initial
}
return {
count,
doubled,
increment,
decrement,
set,
reset,
}
}
Side Effect Composables
Handle side effects cleanly:
// functions/composables/useDebounce.ts
import { ref, watch, type Ref } from '@stacksjs/stx'
export function useDebounce<T>(
value: Ref<T>,
delay: number = 300
): Ref<T> {
const debouncedValue = ref(value.value) as Ref<T>
let timeout: Timer | null = null
watch(value, (newValue) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
// functions/composables/useThrottle.ts
import { ref, watch, type Ref } from '@stacksjs/stx'
export function useThrottle<T>(
value: Ref<T>,
delay: number = 300
): Ref<T> {
const throttledValue = ref(value.value) as Ref<T>
let lastRun = 0
watch(value, (newValue) => {
const now = Date.now()
if (now - lastRun >= delay) {
throttledValue.value = newValue
lastRun = now
}
})
return throttledValue
}
Async Composables
Handle async operations:
// functions/composables/useFetch.ts
// stx has no `shallowRef`: its reactivity tracks the `.value` assignment,
// not the object graph beneath it, so a plain `ref` is already shallow.
import { ref, type Ref } from '@stacksjs/stx'
export interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
export interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
execute: () => Promise<void>
}
export function useFetch<T>(
url: string | Ref<string>,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { immediate = true, initialData = null, onSuccess, onError } = options
const data = ref<T | null>(initialData)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const targetUrl = typeof url === 'string' ? url : url.value
const response = await fetch(targetUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
data.value = result
onSuccess?.(result)
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
error.value = err
onError?.(err)
} finally {
loading.value = false
}
}
if (immediate) {
execute()
}
return {
data,
error,
loading,
execute,
}
}
Validators
Input Validation
Create reusable validators:
// functions/validators/index.ts
export function isEmail(value: string): boolean {
const emailRegex = /[\s@]+@[\s@]+\.[\s@]+$/
return emailRegex.test(value)
}
export function isUrl(value: string): boolean {
try {
new URL(value)
return true
} catch {
return false
}
}
export function isPhone(value: string, country: 'US' | 'UK' = 'US'): boolean {
const patterns = {
US: /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/,
UK: /^(\+44|0)7\d{9}$/,
}
return patterns[country].test(value)
}
export function isStrongPassword(value: string): {
valid: boolean
errors: string[]
} {
const errors: string[] = []
if (value.length < 8) {
errors.push('Must be at least 8 characters')
}
if (!/[A-Z]/.test(value)) {
errors.push('Must contain uppercase letter')
}
if (!/[a-z]/.test(value)) {
errors.push('Must contain lowercase letter')
}
if (!/[0-9]/.test(value)) {
errors.push('Must contain number')
}
if (!/[^A-Za-z0-9]/.test(value)) {
errors.push('Must contain special character')
}
return {
valid: errors.length === 0,
errors,
}
}
Type Utilities
Helper Types
Export useful type utilities:
// functions/types.ts
/**
* Make all properties optional recursively
*/
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
/**
* Make specific properties required
*/
export type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>
/**
* Extract function return type
*/
export type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
T extends (...args: any[]) => Promise<infer R> ? R : never
/**
* Create a type with only picked keys
*/
export type PickByValue<T, V> = Pick<
T,
{ [K in keyof T]: T[K] extends V ? K : never }[keyof T]
>
Exports
Organize Exports
Structure your main export:
// functions/index.ts
// String utilities
export {
capitalize,
titleCase,
slugify,
truncate,
} from './string'
// Array utilities
export {
chunk,
unique,
shuffle,
groupBy,
} from './array'
// Composables
export {
useCounter,
useDebounce,
useThrottle,
useFetch,
useLocalStorage,
} from './composables'
// Validators
export {
isEmail,
isUrl,
isPhone,
isStrongPassword,
} from './validators'
// Types
export type {
DeepPartial,
RequireKeys,
AsyncReturnType,
} from './types'
Testing
Unit Tests
Test functions thoroughly:
// functions/string/capitalize.test.ts
import { describe, it, expect } from 'bun:test'
import { capitalize, titleCase } from './capitalize'
describe('capitalize', () => {
it('capitalizes first letter', () => {
expect(capitalize('hello')).toBe('Hello')
})
it('handles empty string', () => {
expect(capitalize('')).toBe('')
})
it('handles already capitalized', () => {
expect(capitalize('Hello')).toBe('Hello')
})
it('handles single character', () => {
expect(capitalize('a')).toBe('A')
})
})
describe('titleCase', () => {
it('capitalizes all words', () => {
expect(titleCase('hello world')).toBe('Hello World')
})
it('handles mixed case', () => {
expect(titleCase('hELLO wORLD')).toBe('Hello World')
})
})
Testing Composables
// functions/composables/useCounter.test.ts
import { describe, it, expect } from 'bun:test'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('starts at initial value', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('increments', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
})
it('respects max', () => {
const { count, increment } = useCounter(0, { max: 1 })
increment()
increment()
expect(count.value).toBe(1)
})
it('computes doubled', () => {
const { count, doubled } = useCounter(5)
expect(doubled.value).toBe(10)
})
})
Documentation
JSDoc Comments
Document all functions:
/**
* Slugify a string for URL-safe usage
*
* @param str - The string to slugify
* @param options - Slugify options
* @returns The slugified string
*
* @example
* ```typescript
* slugify('Hello World!') // 'hello-world'
* slugify('Café Müller', { transliterate: true }) // 'cafe-muller'
* ```
*/
export function slugify(
str: string,
options: SlugifyOptions = {}
): string {
// implementation
}
Best Practices
- Pure by default - Minimize side effects
- Type everything - Full TypeScript coverage
- Document thoroughly - JSDoc for all exports
- Test comprehensively - Cover edge cases
- Keep it small - One function, one purpose
- Use generics - Make functions flexible
Related
- Getting Started - Library setup
- Components - Component libraries
- Publishing - Publishing workflow