Skip to main content

Data Modeling in TypeScript

Deno KV is currently in beta

Deno KV is currently experimental and subject to change. While we do our best to ensure data durability, data loss is possible, especially around Deno updates.

Executing Deno programs that use KV currently requires the --unstable flag, as below:

deno run -A --unstable my_kv_code.ts

In TypeScript applications, it is usually desirable to create strongly-typed, well-documented objects to contain the data that your application operates on. Using interfaces or classes, you can describe both the shape and behavior of objects in your programs.

If you are using Deno KV, however, there is a bit of extra work required to persist and retrieve objects that are strongly typed. In this guide, we'll cover strategies for working with strongly typed objects going into and back out from Deno KV.

Using interfaces and type assertions

When storing and retrieving application data in Deno KV, you might want to begin by describing the shape of your data using TypeScript interfaces. Below is an object model which describes some key components of a blogging system:

model.ts
export interface Author {
username: string;
fullName: string;
}

export interface Post {
slug: string;
title: string;
body: string;
author: Author;
createdAt: Date;
updatedAt: Date;
}

This object model describes a blog post and an associated author.

With Deno KV, you can use these TypeScript interfaces like data transfer objects (DTOs) - a strongly typed wrapper around the otherwise untyped objects you might send to or receive from Deno KV.

Without any additional work, you can happily store the contents of one of these DTOs in Deno KV.

import { Author } from "./model.ts";

const kv = await Deno.openKv();

const a: Author = {
username: "acdoyle",
fullName: "Arthur Conan Doyle",
};

await kv.set(["authors", a.username], a);

When retreiving this same object from Deno KV, however, it won't by default have type information associated with it. If you know the shape of the object that was stored for the key, however, you can use type assertion to inform the TypeScript compiler about the shape of an object.

import { Author } from "./model.ts";

const kv = await Deno.openKv();

const r = await kv.get(["authors", "acdoyle"]);
const ac = r.value as Author;

console.log(ac.fullName);

You can also specify an optional type parameter for get:

import { Author } from "./model.ts";

const kv = await Deno.openKv();

const r = await kv.get<Author>(["authors", "acdoyle"]);

console.log(r.value.fullName);

For simpler data structures, this technique may be sufficient. But often, you will want or need to apply some business logic when creating or accessing your domain objects. When this need arises, you can develop a set of pure functions that can operate on your DTOs.

Encapsulating business logic with a service layer

When your application's persistence needs become more complex - such as when you need to create secondary indexes to query your data by different keys, or maintain relationships between objects - you will want to create a set of functions to sit on top of your DTOs to ensure that the data being passed around is valid (and not merely typed correctly).

From our business objects above, the Post object is complex enough where it is likely to need a small layer of code to save and retrieve an instance of the object. Below is an example of two functions that wrap the underlying Deno KV APIs, and return strongly typed object instances for the Post interface.

Notably, we need to store an identifier for an Author object, so we can retrieve author information from KV later.

import { Author, Post } from "./model.ts";

const kv = await Deno.openKv();

interface RawPost extends Post {
authorUsername: string;
}

export async function savePost(p: Post): Promise<Post> {
const postData: RawPost = Object.assign({}, p, {
authorUsername: p.author.username,
});

await kv.set(["posts", p.slug], postData);
return p;
}

export async function getPost(slug: string): Promise<Post> {
const postResponse = await kv.get(["posts", slug]);
const rawPost = postResponse.value as RawPost;
const authorResponse = await kv.get(["authors", rawPost.authorUsername]);

const author = authorResponse.value as Author;
const post = Object.assign({}, postResponse.value, {
author,
}) as Post;

return post;
}

This thin layer uses a RawPost interface, which extends the actual Post interface, to include some additional data that is used to reference data at another index (the associated Author object).

The savePost and getPost functions take the place of a direct Deno KV get or set operation, so that they can properly serialize and "hydrate" model objects for us with appropriate types and associations.