Advanced
Work with Composite Primary Keys
Some collections use a composite primary key — two or more fields that together identify a unique item. Junction tables in many-to-many relationships are the most common example. Pass the key as an object containing all key fields.
The SDK serializes composite keys as filter-based lookups — the object key cannot be embedded in the URL path, so the SDK converts it into a filter with _and conditions.
const postTag = await client.PostTags.readOne({
key: { post_id: 'p1', tag_id: 't1' },
fields: ['post_id', 'tag_id'],
});
// Inferred type:
// {
// post_id: string | null;
// tag_id: string | null;
// }
# Composite key uses a filter-based lookup
curl -g "https://example.monospace.io/api/blog/items/post_tags?fields=post_id,tag_id&filter[post_id][_eq]=p1&filter[tag_id][_eq]=t1&limit=1" \
-H "Authorization: Bearer YOUR_API_KEY"
const params = new URLSearchParams({
'fields': 'post_id,tag_id',
'filter[post_id][_eq]': 'p1',
'filter[tag_id][_eq]': 't1',
'limit': '1',
});
const response = await fetch(
`https://example.monospace.io/api/blog/items/post_tags?${params}`,
{
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
const { data } = await response.json();
The key object must include every field that forms the composite primary key. The generated key type enforces this — TypeScript will error if you omit a field.
Update and delete work the same way:
// Update a junction item
await client.PostTags.updateOne({
key: { post_id: 'p1', tag_id: 't1' },
data: { sort_order: 2 },
fields: ['post_id', 'tag_id', 'sort_order'],
});
// Delete a junction item
await client.PostTags.deleteOne({
key: { post_id: 'p1', tag_id: 't1' },
});
# Update a junction item
curl -X PATCH -g "https://example.monospace.io/api/blog/items/post_tags?filter[post_id][_eq]=p1&filter[tag_id][_eq]=t1" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sort_order": 2}'
# Delete a junction item
curl -X DELETE -g "https://example.monospace.io/api/blog/items/post_tags?filter[post_id][_eq]=p1&filter[tag_id][_eq]=t1" \
-H "Authorization: Bearer YOUR_API_KEY"
// Update a junction item
const updateParams = new URLSearchParams({
'filter[post_id][_eq]': 'p1',
'filter[tag_id][_eq]': 't1',
});
await fetch(
`https://example.monospace.io/api/blog/items/post_tags?${updateParams}`,
{
method: 'PATCH',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({ sort_order: 2 }),
},
);
// Delete a junction item
const deleteParams = new URLSearchParams({
'filter[post_id][_eq]': 'p1',
'filter[tag_id][_eq]': 't1',
});
await fetch(
`https://example.monospace.io/api/blog/items/post_tags?${deleteParams}`,
{
method: 'DELETE',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
Use Collections Without a Primary Key
Collections without a primary key only support readMany and readFirst. Single-item operations like readOne, updateOne, and deleteOne are not available — there is no unique identifier to target a specific item.
// readMany — returns all matching items
const logs = await client.AuditLogs.readMany({
fields: ['timestamp', 'action', 'user_id'],
filter: { action: { _eq: 'login' } },
sort: [{ timestamp: { direction: 'desc' } }],
limit: 50,
});
// readFirst — returns the first matching item or null
const latest = await client.AuditLogs.readFirst({
fields: ['timestamp', 'action', 'user_id'],
sort: [{ timestamp: { direction: 'desc' } }],
});
readOne, updateOne, or deleteOne in TypeScript — the type system enforces this automatically. Attempting to call these methods results in a compile-time error, not a runtime one.Access Collections by Name at Runtime
Use $-prefixed methods when collection names are determined at runtime — for example, from user input, configuration, or a loop over collection metadata. These methods accept the collection name as a string argument.
// Read from a collection whose name is a variable
const collectionName = 'articles';
const items = await client.$readMany(collectionName, {
fields: ['id', 'title'],
filter: { status: { _eq: 'published' } },
});
// Single item by key
const item = await client.$readOne(collectionName, {
key: 1,
fields: ['id', 'title', 'content'],
});
// First matching item
const first = await client.$readFirst(collectionName, {
fields: ['id', 'title'],
sort: [{ created_at: { direction: 'desc' } }],
});
Write operations work the same way:
const collectionName = 'articles';
// Create
const created = await client.$createOne(collectionName, {
data: { title: 'New Article', status: 'draft' },
fields: ['id', 'title'],
});
// Create many
const batch = await client.$createMany(collectionName, {
data: [
{ title: 'First', status: 'draft' },
{ title: 'Second', status: 'draft' },
],
fields: ['id', 'title'],
});
// Update one
const updated = await client.$updateOne(collectionName, {
key: 1,
data: { status: 'published' },
fields: ['id', 'status'],
});
// Update many — uses filter, not keys
const updatedMany = await client.$updateMany(collectionName, {
filter: { id: { _in: [1, 2, 3] } },
data: { status: 'archived' },
fields: ['id', 'status'],
});
// Delete one
await client.$deleteOne(collectionName, { key: 1 });
// Delete many — uses filter, not keys
await client.$deleteMany(collectionName, {
filter: { id: { _in: [1, 2, 3] } },
});
The REST API does not distinguish between typed and untyped access — the same endpoints apply regardless of how you determine the collection name.
const collection = 'articles';
const items = await client.$readMany(collection, {
fields: ['id', 'title'],
});
curl "https://example.monospace.io/api/blog/items/articles?fields=id,title" \
-H "Authorization: Bearer YOUR_API_KEY"
const collection = 'articles';
const response = await fetch(
`https://example.monospace.io/api/blog/items/${collection}?fields=id,title`,
{
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
const { data } = await response.json();
$-prefixed methods skip type checking on collection names and fields. Use them for admin panels, migration scripts, or any case where collection names come from user input.Provide a Custom Type for Untyped Operations
Pass a generic type parameter to any $-prefixed method to get typed results without code generation. The SDK applies your type to the return value.
interface Article {
id: string;
title: string;
status: 'draft' | 'published' | 'archived';
created_at: string;
}
// Return type is Article[]
const articles = await client.$readMany<Article>('articles', {
fields: ['id', 'title', 'status', 'created_at'],
filter: { status: { _eq: 'published' } },
});
// Return type is Article
const article = await client.$readOne<Article>('articles', {
key: 'abc-123',
fields: ['id', 'title', 'status', 'created_at'],
});
// Return type is Article | null
const first = await client.$readFirst<Article>('articles', {
fields: ['id', 'title'],
filter: { status: { _eq: 'published' } },
});
This is useful for prototyping before generating types, or when working with collections that are not part of your generated schema (for example, collections in a different project).
Response Envelope (unwrapEnvelope) {#response-envelope}
The Monospace REST API wraps every response in a { data: T } envelope. The unwrapEnvelope option controls whether the SDK strips this wrapper before returning results.
Unwrapped (default)
With unwrapEnvelope: true (the default), the SDK extracts data and returns it directly.
const client = createClient({
url: 'https://example.monospace.io',
project: 'blog',
apiKey: 'YOUR_API_KEY',
});
// Returns Article[] directly
const articles = await client.Articles.readMany({
fields: ['*'],
});
console.log(articles[0].title); // "Getting Started"
curl "https://example.monospace.io/api/blog/items/articles?fields=*" \
-H "Authorization: Bearer YOUR_API_KEY"
# Response:
# {
# "data": [
# { "id": 1, "title": "Getting Started" },
# { "id": 2, "title": "Data Modeling" }
# ]
# }
const response = await fetch(
`https://example.monospace.io/api/blog/items/articles?fields=*`,
{
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
const json = await response.json();
// json.data — you always get the envelope over HTTP
Wrapped
Set unwrapEnvelope: false at the client level to keep the full { data: T } envelope on every response.
const client = createClient({
url: 'https://example.monospace.io',
project: 'blog',
apiKey: 'YOUR_API_KEY',
unwrapEnvelope: false,
});
// Returns { data: Article[] }
const response = await client.Articles.readMany({
fields: ['*'],
});
console.log(response.data); // Article[]
console.log(response.data[0].title); // "Getting Started"
The envelope structure allows future non-breaking additions such as pagination metadata at the top level. Using wrapped mode now means your code is already prepared for those additions.
Per-request override
Override the client default on any individual call by passing the option as a second argument.
const client = createClient({
url: 'https://example.monospace.io',
project: 'blog',
apiKey: 'YOUR_API_KEY',
// unwrapEnvelope defaults to true
});
// Default — returns Article[] directly
const articles = await client.Articles.readMany({
fields: ['id', 'title'],
});
// Override for this request — returns { data: Article[] }
const response = await client.Articles.readMany(
{ fields: ['id', 'title'], limit: 10 },
{ unwrapEnvelope: false },
);
console.log(response.data); // Article[]
This works on any method, including readOne and readFirst:
const response = await client.Articles.readOne(
{
key: 'abc-123',
fields: ['id', 'title', 'status'],
},
{ unwrapEnvelope: false },
);
// response.data — Article
Null Strictness (strictNull) {#null-strictness}
The strictNull option controls how the SDK types fields that might be null at runtime due to conditional permissions.
Why fields can be null at runtime
Monospace supports conditional field-level permissions — a policy can grant read access to a field only when a condition is met. For items where the condition is not satisfied, the field is returned as null.
For example, a policy might grant access to salary only for employees in the current user's department. When reading across departments, salary is a valid number for same-department items and null for others — within the same response.
strictNull addresses the case where access is conditional per item.See Conditional Field-Level Permissions (coming soon) for details on how to configure these policies.Strict (default)
With strictNull: true (the default), all fields become T | null, including non-nullable schema fields like id. This is the safest option — your code handles the possibility that any field could be null for specific items.
const client = createClient({
url: 'https://example.monospace.io',
project: 'blog',
apiKey: 'YOUR_API_KEY',
strictNull: true, // default
});
const article = await client.Articles.readOne({
key: 1,
fields: ['id', 'title', 'body'],
});
// Every field is T | null
article.id; // number | null
article.title; // string | null
article.body; // string | null (even if body is required in the schema)
Relaxed
With strictNull: false, fields follow schema nullability only. A required title field is typed as string, not string | null.
const client = createClient({
url: 'https://example.monospace.io',
project: 'blog',
apiKey: 'YOUR_API_KEY',
strictNull: false,
});
const article = await client.Articles.readOne({
key: 1,
fields: ['id', 'title', 'body'],
});
// Fields follow schema nullability
article.id; // number (primary key, never null)
article.title; // string (required in schema)
article.body; // string | null (nullable in schema)
strictNull: false when you know your auth context has full field-level read access — for example, when using an admin API key. This avoids unnecessary null checks throughout your code.strictNull: false, conditionally restricted fields still return null at runtime, but TypeScript types them as non-nullable. Guard against this by ensuring your API key's role has unconditional access to all fields you query.Effect on relations
strictNull also controls the outer nullability of relational fields.
// strictNull: true (default)
// All relations get an outer | null regardless of schema
// {
// author: { id: string | null; name: string | null } | null;
// articles: { data: { title: string | null }[] } | null;
// }
// strictNull: false
// To-one nullability follows the FK (non-nullable FK = no outer null)
// To-many envelope becomes non-nullable
// {
// author: { id: string; name: string | null };
// articles: { data: { title: string | null }[] };
// }
Per-request override
Override the client default on any individual call by passing strictNull as a second argument.
// Client uses strictNull: true (the default)
// Default: all fields T | null
const artists = await client.Artists.readMany({
fields: ['id', 'name'],
});
// type: { id: string | null; name: string | null }[]
// Per-query override: nullability follows schema
const artistsStrict = await client.Artists.readMany(
{ fields: ['id', 'name'] },
{ strictNull: false },
);
// type: { id: string; name: string | null }[]
// ^ non-nullable in schema
Combine Per-Request Options
Both unwrapEnvelope and strictNull can be set together in the second argument to any CRUD method.
const response = await client.Artists.readMany(
{ fields: ['id', 'name'] },
{ unwrapEnvelope: false, strictNull: false },
);
// type: { data: { id: string; name: string | null }[] }
The second argument accepts QueryOptions:
| Option | Type | Description |
|---|---|---|
unwrapEnvelope | boolean | Override client-level envelope unwrapping for this query |
strictNull | boolean | Override client-level null strictness for this query |
Resolution priority: per-request > client-level > default (true).
Handle Errors
The SDK throws typed error classes for common failure modes. Use instanceof to handle specific errors.
import { MonospaceNotFoundError } from '@monospace/sdk';
try {
await client.Articles.readOne({ key: 999_999, fields: ['title'] });
} catch (error) {
if (error instanceof MonospaceNotFoundError) {
console.log(`Not found: ${error.collection} key=${error.key}`);
// error.collection — 'Articles'
// error.key — 999999
// error.status — 404
}
}
The error hierarchy:
| Error Class | Status | Properties |
|---|---|---|
MonospaceError | any | message, status, source?, meta? |
MonospaceNotFoundError | 404 | collection, key |
MonospaceValidationError | 400 | meta? |
MonospaceAuthError | 401 | default message: 'Authentication failed' |
MonospacePermissionError | 403 | default message: 'Permission denied' |
All error classes extend MonospaceError, which extends the native Error. The source property chains nested errors for debugging.
import {
MonospaceError,
MonospaceNotFoundError,
MonospaceAuthError,
MonospacePermissionError,
MonospaceValidationError,
} from '@monospace/sdk';
try {
await client.Articles.updateOne({
key: 1,
data: { status: 'published' },
fields: ['id'],
});
} catch (error) {
if (error instanceof MonospaceNotFoundError) {
// 404 — item not found
console.log(error.collection, error.key);
} else if (error instanceof MonospaceAuthError) {
// 401 — authentication failed
console.log(error.message);
} else if (error instanceof MonospacePermissionError) {
// 403 — insufficient permissions
console.log(error.message);
} else if (error instanceof MonospaceValidationError) {
// 400 — validation failed
console.log(error.message, error.meta);
} else if (error instanceof MonospaceError) {
// Any other API error
console.log(error.message, error.status);
if (error.source) {
console.log('Caused by:', error.source.message);
}
}
}
See Also
- Client Setup — configure envelope behavior and other client options
- Type System — understand generated types and inference
- Reading Data — REST API reading patterns