import { type Struct, validate } from "superstruct"; import { fromTryCatch } from "./async-op.ts"; type AnyStruct = Struct; type StructsConfig = Record; type SafeStorage> = ReturnType< typeof createSafeStorage >; export type SafeStorageKey> = ReturnType< T["keys"] >[number]; type StructValue = T extends Struct ? V : never; /** * Creates an object with an interface similar to localStorage, but that * enforces that values are parsed and validated before being retrieved, * stringified when being set, and that both keys and values typecheck. * * @example * ```ts * const safeStorage = createSafeStorage({ * currentUser: currentUser(), * sessionInfo: sessionInfo(), * lastSuccessfulSyncTs: number(), * }); * * safeStorage.getItem("currentUser") // Will be valid current user or `null` * safeStorage.setItem("currentUser", { ... }) // Typechecked key and value * safeStorage.removeItem("currentUser") // Typechecked key * safeStorage.clear() // Removes all "owned" keys * * ``` */ export function createSafeStorage(structs: T) { return { getItem(key: K) { const struct = structs[key]; if (!struct) { throw new Error(`No struct found for ${key}`); } const storedValue = localStorage.getItem(key); if (storedValue === null) { return null; } const parsedResult = fromTryCatch(() => JSON.parse(storedValue)); if (parsedResult.isFailure) { console.warn(`Could not parse localStorage value at key '${key}'.`); return null; } const [error, value] = validate(parsedResult.value, struct, { coerce: true, mask: true, }); if (error) { console.warn( `Validation failed for localStorage value at key '${key}'. ${error.message}`, ); } return value as StructValue | null; }, setItem(key: K, value: StructValue) { localStorage.setItem(key, JSON.stringify(value)); }, removeItem(key: keyof T & string) { localStorage.removeItem(key); }, clear() { for (const key in structs) { localStorage.removeItem(key); } }, keys() { return Object.keys(structs) as (keyof T)[]; }, }; }