packages/utils/DOCS.md
The @tldraw/utils package provides foundational utility functions that power the tldraw SDK. It contains pure, reusable helper functions for common programming tasks including array manipulation, object operations, error handling, performance optimization, and media processing.
The utils package serves as the foundation of the tldraw ecosystem, providing battle-tested utilities that other tldraw packages depend on. Every function is designed with type safety, performance, and cross-platform compatibility in mind.
You import utilities directly as named exports:
import { dedupe, rotateArray, partition } from '@tldraw/utils'
import { ExecutionQueue, Result, assert } from '@tldraw/utils'
import { WeakCache, MediaHelpers } from '@tldraw/utils'
The package is completely self-contained with no dependencies on other @tldraw/* packages, making it safe to use in any JavaScript environment.
Important: Many utilities in this package are marked as
@internalin the source code. These are implementation details used within the tldraw ecosystem. While they're exported for internal tldraw packages to use, they may change without notice in minor versions. Focus on the@publicAPIs for stable external usage.
Arrays are everywhere in tldraw - from managing shapes and tools to handling selections and ordering. The array utilities provide type-safe operations that preserve your data's integrity.
You deduplicate arrays using the dedupe function:
import { dedupe } from '@tldraw/utils'
const shapes = [
{ id: 'a', type: 'rect' },
{ id: 'b', type: 'circle' },
{ id: 'a', type: 'rect' }, // duplicate
]
const uniqueShapes = dedupe(shapes, (a, b) => a.id === b.id)
console.log(uniqueShapes) // [{ id: 'a', type: 'rect' }, { id: 'b', type: 'circle' }]
For simple value arrays, you can omit the equality function:
const ids = ['a', 'b', 'c', 'a', 'b']
const uniqueIds = dedupe(ids)
console.log(uniqueIds) // ['a', 'b', 'c']
You can rotate array contents with rotateArray:
import { rotateArray } from '@tldraw/utils'
const tools = ['select', 'draw', 'eraser', 'text']
const rotated = rotateArray(tools, 1)
console.log(rotated) // ['text', 'select', 'draw', 'eraser']
This is particularly useful for cycling through tools or shifting z-order arrangements.
You partition arrays based on conditions using partition:
import { partition } from '@tldraw/utils'
const shapes = [
{ id: 'a', selected: true },
{ id: 'b', selected: false },
{ id: 'c', selected: true },
]
const [selected, unselected] = partition(shapes, (shape) => shape.selected)
console.log(selected) // [{ id: 'a', selected: true }, { id: 'c', selected: true }]
console.log(unselected) // [{ id: 'b', selected: false }]
Note:
partitionis marked as@internalin the source code but is exported for use. It may change without notice in minor versions.
Instead of throwing exceptions, tldraw uses the Result pattern for predictable error handling. This approach makes errors explicit and prevents unexpected crashes.
You create successful results with Result.ok() and errors with Result.err():
import { Result } from '@tldraw/utils'
function parseShape(data: unknown): Result<Shape, string> {
if (typeof data !== 'object' || data === null) {
return Result.err('Invalid data: not an object')
}
// Type checking logic...
return Result.ok(validShape)
}
You check results using the ok property:
const result = parseShape(unknownData)
if (result.ok) {
// TypeScript knows result.value is a Shape
console.log(`Shape type: ${result.value.type}`)
} else {
// TypeScript knows result.error is a string
console.error(`Parse failed: ${result.error}`)
}
Results compose well for sequential operations:
function validateAndCreateShape(data: unknown): Result<ProcessedShape, string> {
const parseResult = parseShape(data)
if (!parseResult.ok) {
return parseResult // Pass through the error
}
const processResult = processShape(parseResult.value)
return processResult
}
When you need to verify assumptions at runtime, use the assertion functions. These provide both runtime safety and TypeScript type narrowing.
The assert function throws if a condition is false:
import { assert } from '@tldraw/utils'
function processShape(shape: unknown) {
assert(shape && typeof shape === 'object', 'Shape must be an object')
// TypeScript now knows shape is object & not null
assert('type' in shape, 'Shape must have a type property')
// TypeScript knows shape has a type property
}
Use assertExists to verify values aren't null or undefined:
import { assertExists } from '@tldraw/utils'
function findShapeById(id: string): Shape {
const shape = shapes.find((s) => s.id === id)
assertExists(shape, `Shape with id ${id} not found`)
// TypeScript knows shape is not undefined
return shape
}
Tip: Assertions are removed from production builds in most bundlers, but the type narrowing still helps during development.
When you need to ensure operations happen in order, use ExecutionQueue. This is particularly important for database writes, file operations, or any sequence where order matters.
You create a queue and push tasks to it:
import { ExecutionQueue } from '@tldraw/utils'
const saveQueue = new ExecutionQueue()
// These will execute in order, not parallel
const save1 = saveQueue.push(() => saveToDatabase(data1))
const save2 = saveQueue.push(() => saveToDatabase(data2))
const save3 = saveQueue.push(() => saveToDatabase(data3))
// All saves complete in order
await Promise.all([save1, save2, save3])
You can add a timeout between tasks and clean up when needed:
// 500ms timeout between tasks
const queue = new ExecutionQueue(500)
// Queue some operations
await queue.push(() => heavyComputation1())
// 500ms delay automatically added
await queue.push(() => heavyComputation2())
// Clean up when done
queue.close()
Note: ExecutionQueue ensures sequential execution even if you don't await individual tasks immediately.
When you need to cache expensive computations tied to object lifecycles, WeakCache automatically cleans up when objects are garbage collected.
You cache results tied to specific objects:
import { WeakCache } from '@tldraw/utils'
const boundingBoxCache = new WeakCache<Shape, BoundingBox>()
function getBoundingBox(shape: Shape): BoundingBox {
return boundingBoxCache.get(shape, (s) => computeBoundingBox(s))
// Expensive computation only runs once per shape
}
Each time you call getBoundingBox with the same shape object, it returns the cached result. When the shape object is garbage collected, the cache entry is automatically cleaned up by the underlying WeakMap.
You can create specialized caches for different computations:
const geometryCache = new WeakCache<Shape, Geometry>()
const selectionCache = new WeakCache<Shape, SelectionBounds>()
function getGeometry(shape: Shape): Geometry {
return geometryCache.get(shape, computeGeometry)
}
function getSelectionBounds(shape: Shape): SelectionBounds {
return selectionCache.get(shape, computeSelectionBounds)
}
Tip: WeakCache is perfect for any computation where the result depends only on the input object and the object reference acts as a natural cache key.
The tldraw editor needs to maintain stable ordering of shapes, even when inserting items between existing ones. The IndexKey system provides fractional indexing for this purpose.
An IndexKey is a special string that maintains lexicographic order:
import { ZERO_INDEX_KEY, getIndexBetween, getIndexAbove } from '@tldraw/utils'
// Start with the zero index
let firstIndex = ZERO_INDEX_KEY // 'a0'
// Get an index above it
let secondIndex = getIndexAbove(firstIndex) // 'a1'
// Insert between them
let middleIndex = getIndexBetween(firstIndex, secondIndex) // 'a0V'
When you need to reorder shapes, use the IndexKey system:
import { getIndicesBetween, getIndicesAbove, getIndicesBelow, sortByIndex } from '@tldraw/utils'
// Insert multiple shapes between two existing ones
const newIndices = getIndicesBetween(belowShape?.index ?? null, aboveShape?.index ?? null, 3)
// Get multiple indices above a shape
const indicesAbove = getIndicesAbove(lastShape?.index ?? null, 3)
// Get multiple indices below a shape
const indicesBelow = getIndicesBelow(firstShape?.index ?? null, 3)
const newShapes = shapeData.map((data, i) => ({
...data,
index: newIndices[i],
}))
// Sort all shapes by their indices
const sortedShapes = allShapes.sort(sortByIndex)
You can also generate a sequence of indices starting from a specific point:
import { getIndices } from '@tldraw/utils'
// Generate 5 indices starting from 'a1' (returns start + n indices)
const indices = getIndices(5, 'a1') // ['a1', 'a2', 'a3', 'a4', 'a5', 'a6']
The IndexKey system ensures that insertion operations always succeed, even with complex reordering scenarios.
Working with images and videos requires careful handling of formats, dimensions, and browser compatibility. The MediaHelpers provide robust utilities for common media operations.
The package also exports constants for supported media types:
import {
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_MEDIA_TYPES,
DEFAULT_SUPPORTED_MEDIA_TYPE_LIST,
} from '@tldraw/utils'
console.log(DEFAULT_SUPPORTED_IMAGE_TYPES)
// ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml', 'image/gif', 'image/apng', 'image/avif']
console.log(DEFAULT_SUPPORT_VIDEO_TYPES)
// ['video/mp4', 'video/webm', 'video/quicktime']
console.log(DEFAULT_SUPPORTED_MEDIA_TYPE_LIST)
// Comma-separated string of all supported types
You can get image dimensions and metadata:
import { MediaHelpers } from '@tldraw/utils'
// Get image dimensions from a Blob
const { w, h } = await MediaHelpers.getImageSize(imageFile)
console.log(`Image is ${w}x${h} pixels`)
// Load image from URL and get dimensions together
const { image, w: width, h: height } = await MediaHelpers.getImageAndDimensions(imageUrl)
// Use the loaded image element and dimensions
Check media formats and capabilities:
const isImage = MediaHelpers.isImageType('image/png') // true
const isStatic = MediaHelpers.isStaticImageType('image/gif') // false
const isAnimated = MediaHelpers.isAnimatedImageType('image/gif') // true
const isVector = MediaHelpers.isVectorImageType('image/svg+xml') // true
// Check if a specific file is animated
const animated = await MediaHelpers.isAnimated(gifFile)
Process video files and extract frames:
// Get video dimensions from a Blob
const { w, h } = await MediaHelpers.getVideoSize(videoFile)
// Load a video from URL
const videoElement = await MediaHelpers.loadVideo(videoUrl)
// Extract a frame as a data URL from loaded video element
const frameDataUrl = await MediaHelpers.getVideoFrameAsDataUrl(videoElement, 0)
High-frequency events like mouse moves and resize events need careful handling to maintain smooth performance.
Use fpsThrottle for smooth 60fps updates:
import { fpsThrottle } from '@tldraw/utils'
const updateCanvas = fpsThrottle(() => {
// This will run at most once per frame (16.67ms)
redrawCanvas()
})
// Call as often as you want - it's automatically throttled
document.addEventListener('mousemove', updateCanvas)
For less critical updates, use throttleToNextFrame:
import { throttleToNextFrame } from '@tldraw/utils'
const updateUI = throttleToNextFrame(() => {
// Batches multiple calls into the next animation frame
updateStatusBar()
})
// Returns a cancel function
const cancel = updateUI()
// Call cancel() to prevent execution if needed
Note:
throttleToNextFramebatches multiple calls to the same function and executes it only once on the next frame.
Use debounce for operations that should only happen after user input stops:
import { debounce } from '@tldraw/utils'
const saveDocument = debounce(async () => {
await saveToServer(document)
console.log('Document saved!')
}, 1000)
// Call whenever document changes
document.addEventListener('input', saveDocument)
// Only saves 1 second after user stops typing
The debounced function returns a promise and includes a cancel method:
const debouncedSave = debounce(saveToServer, 1000)
// Start a save operation
const savePromise = debouncedSave()
// Cancel if needed
debouncedSave.cancel()
Understanding where time is spent helps optimize tldraw applications.
Use PerformanceTracker for detailed timing analysis:
import { PerformanceTracker } from '@tldraw/utils'
const tracker = new PerformanceTracker()
tracker.start('render')
renderShapes()
tracker.stop()
tracker.start('interaction')
handleUserInteraction()
tracker.stop()
Tip: Performance measurements integrate with browser DevTools Performance tab for detailed analysis.
The utils package includes mathematical helpers for interpolation and deterministic randomness.
Use lerp and invLerp for smooth transitions:
import { lerp, invLerp } from '@tldraw/utils'
// Linear interpolate between two values
const interpolated = lerp(0, 100, 0.5) // 50
// Inverse interpolation - find t given result
const t = invLerp(0, 100, 25) // 0.25
Use modulate to map values between different ranges:
import { modulate } from '@tldraw/utils'
// Map a value from one range to another
const result = modulate(5, [0, 10], [0, 100]) // 50
// With clamping to prevent out-of-bounds results
const clamped = modulate(15, [0, 10], [0, 100], true) // 100 (clamped)
Use rng for repeatable pseudo-random sequences:
import { rng } from '@tldraw/utils'
// Create a seeded random number generator
const random = rng('my-seed')
const num1 = random() // Always the same for this seed
const num2 = random() // Next number in sequence
// Different seed produces different sequence
const otherRandom = rng('other-seed')
const different = otherRandom() // Different value
Tip: The
rngfunction returns values between -1 and 1, making it useful for generating consistent random variations. You can normalize to other ranges as needed.
Browser storage operations need careful error handling for quota limits and privacy modes.
The storage utilities handle errors gracefully (note that these functions are marked as @internal but are exported for use):
import { getFromLocalStorage, setInLocalStorage, clearLocalStorage } from '@tldraw/utils'
// These handle quota exceeded errors and privacy mode
setInLocalStorage('user-preferences', JSON.stringify(preferences))
const saved = getFromLocalStorage('user-preferences')
// Clear all data when needed
clearLocalStorage()
Session storage works identically:
import { getFromSessionStorage, setInSessionStorage, clearSessionStorage } from '@tldraw/utils'
// Temporary data for the current session
setInSessionStorage('current-tool', 'select')
const currentTool = getFromSessionStorage('current-tool')
// Clear all session data when needed
clearSessionStorage()
The FileHelpers class provides utilities for working with files and data conversion.
Convert between different file formats:
import { FileHelpers } from '@tldraw/utils'
// Convert blob to data URL
const dataUrl = await FileHelpers.blobToDataUrl(imageBlob)
console.log(dataUrl) // "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
// Convert blob to text
const textContent = await FileHelpers.blobToText(textBlob)
// Fetch URL and convert to different formats
const buffer = await FileHelpers.urlToArrayBuffer('https://example.com/image.png')
const blob = await FileHelpers.urlToBlob('https://example.com/data.json')
const urlAsDataUrl = await FileHelpers.urlToDataUrl('https://example.com/image.svg')
Modify file MIME types while preserving content:
// Change MIME type of a Blob
const newBlob = FileHelpers.rewriteMimeType(originalBlob, 'image/webp')
// Change MIME type of a File (preserves filename)
const newFile = FileHelpers.rewriteMimeType(originalFile, 'application/json')
Parsing URLs from user input requires careful validation:
import { safeParseUrl } from '@tldraw/utils'
function handleUserUrl(input: string) {
const url = safeParseUrl(input)
if (url) {
console.log(`Valid URL: ${url.href}`)
return url
} else {
console.log('Invalid URL provided')
return null
}
}
Note:
safeParseUrlreturnsundefinedfor invalid URLs instead of throwing exceptions.
The Timers class helps manage timeouts and intervals with automatic cleanup:
import { Timers } from '@tldraw/utils'
class MyComponent {
private timers = new Timers()
startPeriodicUpdate() {
// Set timers with context IDs for organization
this.timers.setTimeout('component', () => this.autoSave(), 5000)
this.timers.setInterval('component', () => this.refresh(), 1000)
this.timers.requestAnimationFrame('component', () => this.render())
}
cleanup() {
// Clears all timers for this context
this.timers.dispose('component')
// Or dispose all contexts
this.timers.disposeAll()
}
// You can also get context-bound timer functions
getContextTimers() {
return this.timers.forContext('component')
}
}
You can add debugging context to errors:
import { annotateError, getErrorAnnotations } from '@tldraw/utils'
try {
performRiskyOperation()
} catch (error) {
annotateError(error, {
tags: { operation: 'shape-creation' },
extras: { shapeId: 'shape-123' },
})
// Later, retrieve the context
const annotations = getErrorAnnotations(error)
console.log('Error context:', annotations)
throw error // Re-throw with added context
}
Several utility functions provide common functionality:
Generate unique identifiers for objects:
import { uniqueId, mockUniqueId, restoreUniqueId } from '@tldraw/utils'
// Generate a unique ID
const id = uniqueId() // 'VxhUYo3k8GsLmWkjhGq9e'
// Mock IDs for testing (returns predictable sequence)
mockUniqueId(() => 'mock-id-0')
const testId1 = uniqueId() // 'mock-id-0'
const testId2 = uniqueId() // 'mock-id-0'
// Restore normal ID generation
restoreUniqueId()
Generate consistent hashes for deduplication and caching:
import { getHashForString, getHashForObject, getHashForBuffer, lns } from '@tldraw/utils'
// Hash a string
const stringHash = getHashForString('hello world') // '1794106052'
// Hash an object (uses JSON.stringify internally)
const objectHash = getHashForObject({ name: 'Alice', age: 30 })
// Hash binary data
const buffer = new ArrayBuffer(8)
const bufferHash = getHashForBuffer(buffer)
// Locale-normalized string for consistent hashing across cultures
const normalized = lns('Café') // Handles unicode normalization
Sort objects by common properties:
import { sortById } from '@tldraw/utils'
const items = [
{ id: 'c', name: 'Charlie' },
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' },
]
const sorted = items.sort(sortById)
// [{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }, { id: 'c', name: 'Charlie' }]
Extract values from iterables:
import { getFirstFromIterable } from '@tldraw/utils'
const set = new Set([1, 2, 3])
const first = getFirstFromIterable(set)
const map = new Map([
['a', 1],
['b', 2],
])
const firstValue = getFirstFromIterable(map)
The @bind decorator ensures methods are properly bound to their class instance:
import { bind } from '@tldraw/utils'
class EventHandler {
name = 'MyHandler'
@bind
handleClick(event: MouseEvent) {
console.log(`${this.name} handled click`) // 'this' is always correct
}
}
const handler = new EventHandler()
// Safe to use as callback - 'this' binding preserved
element.addEventListener('click', handler.handleClick)
Some utilities are particularly helpful during development:
import { warnOnce, exhaustiveSwitchError } from '@tldraw/utils'
// Warn about deprecated usage, but only once
function oldFunction() {
warnOnce('oldFunction is deprecated, use newFunction instead')
// Continue with implementation...
}
// Ensure switch statements are exhaustive
function handleShapeType(shape: Shape) {
switch (shape.type) {
case 'rect':
return handleRect(shape)
case 'circle':
return handleCircle(shape)
default:
// TypeScript error if new shape types are added but not handled
throw exhaustiveSwitchError(shape)
}
}
Type guards provide runtime checking with TypeScript integration:
import { isDefined, isNonNull, isNonNullish } from '@tldraw/utils'
function processUserInput(data: unknown) {
if (isDefined(data)) {
// TypeScript knows data is not undefined
console.log('Data provided:', data)
}
if (isNonNullish(data)) {
// TypeScript knows data is not null or undefined
return processData(data)
}
throw new Error('Invalid input data')
}
When creating custom shapes, utils provide essential building blocks:
import { WeakCache, Result, assert, getIndexBetween } from '@tldraw/utils'
class CustomShapeUtil extends BaseBoxShapeUtil<CustomShape> {
private geometryCache = new WeakCache<CustomShape, Geometry>()
getGeometry(shape: CustomShape): Geometry {
return this.geometryCache.get(shape, (s) => {
return this.computeComplexGeometry(s)
})
}
canReceiveNewChildIndex(shape: CustomShape, droppingShape: Shape): boolean {
// Use Result pattern for complex validation
const validation = this.validateChildShape(droppingShape)
return validation.ok
}
private validateChildShape(shape: Shape): Result<true, string> {
if (!this.isCompatibleChild(shape)) {
return Result.err(`${shape.type} cannot be a child of CustomShape`)
}
return Result.ok(true)
}
}
Tools benefit from utils for state management and performance:
import { debounce, throttleToNextFrame, ExecutionQueue, partition } from '@tldraw/utils'
class CustomTool extends StateNode {
private updateQueue = new ExecutionQueue(16) // 60fps limit
private debouncedSave = debounce(() => this.saveToolState(), 1000)
onPointerMove = throttleToNextFrame((info: TLPointerEventInfo) => {
this.updateQueue.push(() => this.handleMove(info))
this.debouncedSave()
})
private handleMove(info: TLPointerEventInfo) {
const shapes = this.editor.getCurrentPageShapes()
const [movingShapes, staticShapes] = partition(shapes, (shape) => this.isShapeMoving(shape))
// Update only the shapes that need it
this.updateMovingShapes(movingShapes)
}
}
Robust applications use Result patterns throughout:
import { Result, assertExists } from '@tldraw/utils'
class DocumentManager {
async loadDocument(id: string): Promise<Result<Document, string>> {
try {
const data = await this.storage.load(id)
assertExists(data, `Document ${id} not found`)
const parseResult = this.parseDocument(data)
if (!parseResult.ok) {
return Result.err(`Failed to parse document: ${parseResult.error}`)
}
return Result.ok(parseResult.value)
} catch (error) {
return Result.err(`Storage error: ${error.message}`)
}
}
private parseDocument(data: unknown): Result<Document, string> {
// Detailed parsing with Result pattern...
return Result.ok(validDocument)
}
}
The @tldraw/utils package provides:
Type Safety: Every utility maintains and enhances TypeScript's type information, preventing runtime errors and improving developer experience.
Performance: Optimized implementations with caching, throttling, and memory management prevent performance bottlenecks in complex applications.
Reliability: Comprehensive error handling with Result patterns and assertions creates predictable, debuggable applications.
Cross-Platform: Consistent behavior across browsers, Node.js, and other JavaScript environments with appropriate polyfills and fallbacks.
These utilities form the foundation that makes tldraw's complex canvas operations feel smooth and reliable. Whether you're building custom shapes, tools, or integrating tldraw into larger applications, these utilities provide the building blocks for professional-grade functionality.