The DatoCMS Blog

Introducing a brand new Typescript client!

Posted on April 27th, 2022 by Stefano Verna

We're happy to announce the initial release of a brand new, 100% TypeScript client for the Content Management API of DatoCMS.

TypeScript has taken over the world. And rightfully so. We've long wanted to make our client experience more convenient on TypeScript-ready environments, and the time has finally come!

Give it a try on CodeSandbox and make sure to enjoy the autocompletion! 😜

How to install the new client

The new client was rewritten from scratch, and considering the number of substantial changes to the interface compared to the previous version, we decided to release it as a new package, with a different name.

Depending on the environment you're working (browser or NodeJS), you can install one of these two packages:

npm install @datocms/cma-client-browser
npm install @datocms/cma-client-node

They both offer the same interface:

import { buildClient } from '@datocms/cma-client-node';
const client = buildClient({ apiToken: '<YOUR_TOKEN>' });
const model = await client.itemTypes.create({
name: 'Article',
api_key: 'article',
});

The only difference between the two — at least for the moment — is in the methods available to upload new assets:

// available on @datocms/cma-client-browser
const upload = await client.uploads.createFromFileOrBlob({
fileOrBlob: document.getElementById('fileInput').files[0],
});
// available on @datocms/cma-client-node
const upload = await client.uploads.createFromLocalFile({
localPath: './image.png',
});
const upload = await client.uploads.createFromUrl({
url: 'https://example.com/image.png',
});

What's better?

Let's start with the biggest wins:

  • First and foremost: everything is now fully typed!

  • The package is ESM (ECMAScript modules) ready, and tree-shakable by bundlers;

  • Smaller package size: far less dependencies carried around;

But there are also a few more niceties to explore!

File upload methods for example now return cancelable promises that you can abort:

const uploadPromise = client.uploads.createFromFileOrBlob({
fileOrBlob: document.getElementById('fileInput').files[0],
});
setTimeout(() => { uploadPromise.cancel(); }, 1000);
await uploadPromise;

Every API endpoint also offers two different methods: one which simplifies a little bit the request/response payload coming to/from the API to make it less verbose (ie. create), and another which is 100% pass-through (ie. rawCreate):

// SIMPLIFIED VERSION
const model = await client.itemTypes.create({
name: 'Article',
api_key: 'article',
}); // => { id: '42123', name: 'Article', ...
// RAW VERSION
const response = await client.itemTypes.rawCreate({
type: 'item_type',
attributes: {
name: 'Article',
api_key: 'article',
},
}); // => { data: { id: '42123', attributes: { name: 'Article', ...

What changed from the old client?

We tried to keep the new interface as similar as possible to the old one, but due to the nature of TypeScript, and our experience with the old client, there are some differences to be aware of.

Client instantiation

As we just saw in the previous examples, to instantiate a new client you need to use the buildClient() function:

// NEW CLIENT
import { buildClient, LogLevel } from '@datocms/cma-client-node';
const client = buildClient({
apiToken: '<YOUR_TOKEN>',
environment: 'foo',
logLevel: LogLevel.BASIC,
});
// OLD CLIENT
import { SiteClient } from 'datocms-client';
const client = new SiteClient(
'YOUR_TOKEN',
{ environment: 'foo', logApiCalls: 1 },
);

Snake cased payloads

Both the payload of the requests and responses are now in snake_case instead of camelCase.

This was a difficult decision, but the automatic conversions that the old client made were often a source confusion/issues.

Fortunately, if you plan to migrate to the new client, the type-checking will make it extremely visible when payloads are not as expected, so you can correct them appropriately:

// NEW CLIENT
const article = await client.itemTypes.create({
name: 'Article',
api_key: 'article',
}); // => { id: '34123', api_key: 'article', ... }
// OLD CLIENT
const article = await client.itemTypes.create({
name: 'Article',
apiKey: 'article',
}); // => { id: '34123', apiKey: 'article', ... }

Relationships between entities

Relationships are now expressed with a { type, id } tuple, and not simply with the ID. A nice side-effect is that this makes it easy to re-use already fetched entities:

// NEW CLIENT
await client.items.create({
title: 'New article!',
// you can also pass the tuple { type: 'item_type', id: '34123' }
item_type: article,
})
// OLD CLIENT
await client.items.create({
title: 'New article!',
itemType: article.id,
})

Collections and pagination

To retrieve a list of entities (blog posts, models, etc.) you need to use the list() method — before it was called all():

// NEW CLIENT
await client.itemTypes.list();
// OLD CLIENT
await client.itemTypes.all();

We decided to make this change because there are endpoints in which results are paginated, so the all() method didn't really return ALL the entities. The name was quite confusing/misleading for new users.

If you really want to iterate over EVERY entity, you can now use an async iteration statement with the listPagedIterator() method, which will automatically go through every page of results:

// NEW CLIENT
for await (const article of client.items.listPagedIterator({ filter: { type: "article" }})) {
console.log(article.title);
}
// OLD CLIENT
const allArticles = await client.items.all(
{ filter: { type: "article" }},
{ allPages: true },
);

What happens to the old client?

Nothing really! 😃

Of course we're going to promote the new client in our docs, because we think it's better in every way, but you can keep using the old one as long as you prefer.

The documentation will still offer examples for the old package, which is also going to be mantained for the foreseeable future.

In short, no pressure in upgrading your codebase!

Future improvements

The methods for creating and updating records are the only ones that are not fully typed, as different records can have different payloads based on the model they belong to.

To be more clear, currently the types associated with a record returned by the client are something along the lines of:

type Item = {
id: string;
item_type: ItemTypeData;
[k: string]: unknown;
}

While this is technically correct, we're going to investigate ways that are semantic in TypeScript to offer specific types for every record, to offer the same level of comfort and type-safety in managing them as well.

Next steps: feedback, documentation and CLI!

We want to take these weeks to listen to your feedback and possibly fine-tune some of the choices we made, so please get in touch if you've got opinions!

In the following weeks we're going to update all the documentation in our website to promote and suggest the use of the new client.

As you might already know, the old datocms-client package exposed both the API client and our CLI — that is, the dato executable.

In hindsight the choice to offer both in one package was a mistake, and we're not going to repeat it, so the next logical step for us will be to work on a new, fully-typed CLI package, separate from the client, with an improved developer experience. This new CLI is going to eventually replace the old one for the better.

Stay tuned for all the updates, and let us know what you think!