Client SDK

Advanced

Handle composite keys, dynamic collections, and other special cases.

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;
// }

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' },
});

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.

reading keyless collections
// 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' } }],
});
Collections without a primary key will not expose 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.

dynamic collection access
// 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:

dynamic writes
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'],
});
$-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.

custom type parameter
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).

Custom types on untyped operations are not validated against the actual schema. Runtime mismatches are silent — if the API returns fields that do not match your interface, TypeScript will not catch it.

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"

Wrapped

Set unwrapEnvelope: false at the client level to keep the full { data: T } envelope on every response.

client-level wrapped mode
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.

per-request envelope override
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:

envelope on readOne
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.

This is different from fields the user has no permission to read at all. Fully restricted fields are either omitted from the response (when requested via wildcard) or cause a permission error (when requested explicitly). 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.

strictNull: true (default)
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.

strictNull: false
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)
Use 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.
With 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 and relations
// 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.

per-query strictNull
// 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.

combining options
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:

OptionTypeDescription
unwrapEnvelopebooleanOverride client-level envelope unwrapping for this query
strictNullbooleanOverride 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.

error handling
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 ClassStatusProperties
MonospaceErroranymessage, status, source?, meta?
MonospaceNotFoundError404collection, key
MonospaceValidationError400meta?
MonospaceAuthError401default message: 'Authentication failed'
MonospacePermissionError403default message: 'Permission denied'

All error classes extend MonospaceError, which extends the native Error. The source property chains nested errors for debugging.

error hierarchy
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

Copyright © 2026