Local storage is a great solution for quick prototyping.
Designing a complete database schema and hooking my baby project to a remote database is too much work. I don't even know if this project will last for the next 2 days. Why should I spend 3 hours setting up a backend database?
At the same time, local storage is often a pain.
I want an SQL-like experience, schema validation, and a simple API to write, read, and delete from local storage
Here you can find my own generic implementation of a local storage database API. It comes with the following features out of the box:
- Powerful schema validation (using
zod) - Error handling and functional programming (using
fp-ts) - Simple API to write, read, update, and delete in local storage
Prerequisites
This implementation uses Typescript to define a type-safe interface for our database.
Furthermore, we are also using the following packages, make sure to install them:
zod: Schema validation, make sure that the data inside the database has the right formatfp-ts: Functional programming types, used to provide a type-safe and composable interface for the database API
Install them all using the following command:
npm install zod fp-tsGeneric interface for any storage implementation (IStorageApi)
Below you can find the implementation of a generic type for a complete API that defines all the methods necessary to interact with your database.
Every concrete implementation of our database will be of type IStorageApi:
Schema:zodschema used for validationError: any kind of error returned by the API (usingEitherfromfp-ts)Table: defined the "tables" of our database. This makes possible to use the same API for multiple storages. It works similar to akeyfor local storage
import * as RTE from "fp-ts/ReaderTaskEither";
import { z } from "zod";
type IStorageApi<Schema extends z.ZodTypeAny, Error, Table extends string> = (
schema: Schema
) => {
/**
* Given a `Table`, return all the data inside storage.
*/
readAll: RTE.ReaderTaskEither<Table, Error, z.output<Schema>[]>;
/**
* Read all the data inside `Table` filtered by the `check` function.
*/
readWhere: RTE.ReaderTaskEither<
[Table, (check: z.output<Schema>) => boolean],
Error,
z.output<Schema>[]
>;
/**
* Given a `Table` and a value, write the value inside storage (single value).
*/
write: RTE.ReaderTaskEither<
[Table, z.output<Schema>],
Error,
z.output<Schema>
>;
/**
* Given a `Table` and a list of values, write all the values inside storage.
*/
writeAll: RTE.ReaderTaskEither<
[Table, z.output<Schema>[]],
Error,
readonly z.output<Schema>[]
>;
/**
* Delete all the data inside `Table`.
*/
deleteAll: RTE.ReaderTaskEither<Table, Error, unknown>;
/**
* Update all the data inside the given `Table` based on the
* given `check` function (**map**).
*/
update: RTE.ReaderTaskEither<
[Table, (check: z.output<Schema>) => z.output<Schema>],
Error,
readonly z.output<Schema>[]
>;
};
export type { IStorageApi };Local storage database implementation
All we need to do now is to implement the IStorageApi interface for localStorage.
Here below the complete implementation:
import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
import { pipe } from "fp-ts/function";
import * as IOE from "fp-ts/IOEither";
import * as TE from "fp-ts/TaskEither";
import { z } from "zod";
import { IStorageApi } from "./istorage.api";
type LocalStorageApi<
Schema extends z.ZodTypeAny,
Table extends string
> = IStorageApi<Schema, string, Table>;
type LocalStorageApiSchema<
Schema extends z.ZodTypeAny,
Table extends string
> = Parameters<LocalStorageApi<Schema, Table>>[0];
type LocalStorageApiMethod<
Schema extends z.ZodTypeAny,
Table extends string
> = ReturnType<LocalStorageApi<Schema, Table>>;
type LocalStorageApiData<
Schema extends z.ZodTypeAny,
Table extends string
> = z.output<LocalStorageApiSchema<Schema, Table>>;
const readAll =
<Schema extends z.ZodTypeAny, Table extends string>(
validation: Schema
): LocalStorageApiMethod<Schema, Table>["readAll"] =>
(table) =>
pipe(
() =>
E.tryCatch(
() => localStorage.getItem(table),
() => "Error when loading from local storage"
),
IOE.chain((item) =>
item === null
? IOE.fromEither(E.of<string, z.output<typeof validation>[]>([]))
: pipe(
() =>
E.tryCatch(
() => JSON.parse(item) as unknown,
() => "Error when parsing to JSON"
),
IOE.chain((json) =>
pipe(
z.array(validation).safeParse(json),
(parsed): E.Either<string, z.output<typeof validation>[]> =>
parsed.success
? E.of(parsed.data)
: E.left(
`Error when parsing local data: ${parsed.error.issues[0].message}`
),
IOE.fromEither
)
)
)
),
TE.fromIOEither
);
const readWhere =
<Schema extends z.ZodTypeAny, Table extends string>(
validation: Schema
): LocalStorageApiMethod<Schema, Table>["readWhere"] =>
([table, check]) =>
pipe(table, readAll(validation), TE.map(A.filter(check)));
const write =
<Schema extends z.ZodTypeAny, Table extends string>(
validation: Schema
): LocalStorageApiMethod<Schema, Table>["write"] =>
([table, item]) =>
pipe(
validation.safeParse(item),
(parsed): E.Either<string, z.output<Schema>> =>
parsed.success
? E.of(parsed.data)
: E.left(
`Invalid schema for writing data: ${parsed.error.issues[0].message}`
),
TE.fromEither,
TE.chain((validItem) =>
pipe(table, readAll(validation), TE.map(A.append(validItem)))
),
TE.chain((newData) =>
pipe(
() =>
E.tryCatch(
() => localStorage.setItem(table, JSON.stringify(newData)),
() => "Error while saving data"
),
TE.fromIOEither,
TE.map(() => item)
)
)
);
const writeAll =
<Schema extends z.ZodTypeAny, Table extends string>(
validation: Schema
): LocalStorageApiMethod<Schema, Table>["writeAll"] =>
([table, items]) =>
pipe(
table,
readAll(validation),
TE.map(A.concat(items)),
TE.chain((newData) =>
pipe(
() =>
E.tryCatch(
() => localStorage.setItem(table, JSON.stringify(newData)),
() => "Error while saving data"
),
TE.fromIOEither,
TE.map(() => items)
)
)
);
const deleteAll =
<Schema extends z.ZodTypeAny, Table extends string>(): LocalStorageApiMethod<
Schema,
Table
>["deleteAll"] =>
(table) =>
pipe(
() =>
E.tryCatch(
() => localStorage.removeItem(table),
() => `Error while deleting all storage in '${table}' schema`
),
TE.fromIOEither
);
const update =
<Schema extends z.ZodTypeAny, Table extends string>(
validation: Schema
): LocalStorageApiMethod<Schema, Table>["update"] =>
([table, check]) =>
pipe(
table,
readAll(validation),
TE.map(A.map(check)),
TE.chain((newData) =>
pipe(
table,
deleteAll(),
TE.chain(() => writeAll(validation)([table, newData]))
)
)
);
const localStorageApi = <Schema extends z.ZodTypeAny, Table extends string>(
schema: Schema
): LocalStorageApiMethod<Schema, Table> => ({
readAll: readAll(schema),
readWhere: readWhere(schema),
write: write(schema),
update: update(schema),
writeAll: writeAll(schema),
deleteAll: deleteAll(),
});
export type { LocalStorageApiData };
export { localStorageApi };Type helpers
Initially we define some types used in our implementation.
All these types derive from the IStorageApi interface.
Notice that we leave the
Schemaas generic. By doing this, we can use this same API for different schemas.
These types are all internal. The only exported type is LocalStorageApiData, which represents the shape of the database schema.
API implementation
Next we define the actual implementation of our local storage API.
Notice how each method is implemented in a separate function, instead of all together in the final
localStorageApi. This allows to use different functions inside each other (for example, we are usingreadAllinside many of the other functions)
Some main points to highlight:
- We consider any access to
localStorageas potentially unsafe. For this reason, any request tolocalStorageis wrapped insideIOEither - We used
zod'ssafeParsemethod to validate the data. In this implementation we return an error if the shape of the data is invalid (both for read and write operations). This means that if the any value in local storage gets corrupted (i.e. wrong format), then all reads operation will fail! - We are required to read all the data from local storage at every request, even when we then filter the result. This is not the most efficient solution, but hey, it's local storage 💁🏼♂️
Erroris defined asstring, and it's not left generic likeSchema- We use
JSON.parseandJSON.stringifyto read and write data in local storage - Even if reading from local storage is a synchronous operation, the API returns
TaskEither(asynchronous)
We then define a localStorageApi, derived from IStorageApi, and we export it.
How to use the local storage database
We are now free to define all the implementation that we need on top of this API.
Below an example of a concrete implementation:
import { z } from "zod";
import { localStorageApi } from "../local-storage.api";
const schema = z.object({
age: z
.number()
.min(0, {
message: "No human has less than 0 years, or not?",
})
.max(120, {
message: "You are too old for this app",
}),
link: z
.string()
.url({
message: "Only valid URLs allowed here (or none)",
})
.optional(),
});
const userStorageApi = localStorageApi<typeof schema, "User">(schema);
type UserStorageApiData = z.output<typeof schema>;
export type { UserStorageApiData };
export { userStorageApi };As easy as it gets 🌈
Now you can simply use the API in you app:
Have fun, and good prototyping 👋
