Validating output

Every conversion hands back degradation records saying what the converter had to change. validateBlocks is a separate, offline assertion that the output sits inside Slack's documented limits. Between them you can tell whether what reaches Slack is what the model wrote.

Degradation records

Here is a document that cannot survive intact: Slack headers stop at four, and its table is one column wider than the native block allows. The records sit under the render, naming what changed and why, and the Block Kit JSON is one disclosure below them:

##### Level five heading

Slack headers stop at four, and this table is one column past the native limit.

| a | b | c | d | e | f | g | h | i | j | k | l | m | n | o | p | q | r | s | t | u |
| - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
Block Kit JSON

With valid configuration and slackmark's own collaborators, unsupported Markdown does not throw. Fallback, truncation, omission, or substitution appends a structured record ({ nodeType, from, to, reason, path }; the Degradation reference has the full shape). Configuration and operation errors still reject. A useful production pattern is to log degradations with message metadata:

import { convert } from "slackmark";

const { blocks, text, degradations, receipts } = await convert(modelOutput);

if (degradations.length > 0) {
  logger.info({ channel, degradations }, `conversion degraded ${degradations.length} node(s)`);
}

const prepared = { channel, blocks, text, receipts };
await postMessage(prepared); // retain prepared on failure

Degradations are ordinary and expected. A level-5 heading or a 25-column table has no lossless Slack form. A non-empty array tells you precisely what changed and why; an empty array means no fallback was recorded. One known gap: raw block HTML handling (tag stripping, <details> flattening to plain text) does not record every dropped detail today, so treat HTML-heavy input with extra care.

validateBlocks: an offline check

validateBlocks asserts the documented Block Kit limits for the block types slackmark emits (block count, per-block text lengths, table dimensions, chart limits, link URL schemes, cumulative character budgets) without any network call. It throws with a list of path-annotated issues when a limit is violated:

import { convert, validateBlocks } from "slackmark";

const { blocks, text, receipts } = await convert(markdown);

const validated = validateBlocks(blocks);

const prepared = { channel, blocks: validated, text, receipts };
await postMessage(prepared); // retain prepared on failure

Scope it correctly. The input type is slackmark's Block union and only those block shapes are checked, which means it is not a general Block Kit validator. Block types slackmark never emits (actions, input, …) are outside the union and go unvalidated. Passing it does not guarantee Slack accepts the payload on every surface either, which is what capability profiles are for.

Output from the built-in conversion pipeline always passes. The conversion budgets exist precisely so it does, and the fuzz suite enforces it. The validator earns its keep at the extension seams:

  • Custom adapters. Adapters you prepend via createConverter({ adapters }) can emit over-limit blocks (say, a 151-character header); validate and test those pipelines.
  • Offline development. Catch limit violations without hitting the Slack API.

The return value is the same array typed as ValidatedBlocks, so downstream code can require proof of validation in its signature.

What throws and what never does

InputBehavior
Hostile Markdown with valid configuration/default collaboratorsReturns valid output plus degradations
Content selects byte output without uploader and no later recoveryRejects terminal ConfigurationError
Invalid options or custom collaborator contract/runtime failureRejects typed ConfigurationError
Caller abort, timeout, or shutdownRejects typed operation error
Limit violation in validateBlocksThrows with path-annotated issues

Configuration errors are programmer errors and fail fast on purpose. Once the configuration is valid, no markdown input can make convert() throw.

Recognising an error you caught

Use the exported guards rather than instanceof:

import { isConfigurationError, isOperationError, OperationErrorCode } from "slackmark";
import { isSlackUploadError } from "slackmark/slack";

export function describeFailure(error: unknown): string {
  if (isConfigurationError(error)) return `fix the configuration: ${error.message}`;
  if (isOperationError(error)) {
    if (error.code === OperationErrorCode.DeadlineExceeded) return "timed out";
    if (error.code === OperationErrorCode.Shutdown) return "shutting down";
    return "cancelled by the caller";
  }
  if (isSlackUploadError(error)) return `slack upload failed at ${error.stage}`;
  return "unexpected failure";
}

slackmark, slackmark/renderers, and slackmark/slack are bundled independently, so each carries its own copy of the error classes. An error raised inside one entry fails instanceof against a class imported from another. Catch an abort from FetchSlackUploader, test it against OperationError from slackmark, and you get a false negative. The guards read a shared registry symbol and are correct from any entry. instanceof still works when the error and the class come from the same entry, and narrowing on code is preferred over testing a specific subclass.