Validating output

Every successful conversion hands back degradation records saying what the converter had to change. validateBlocks is a separate, offline assertion that the output sits inside the limits it checks. Together they catch library-recorded transformations and local limit violations; they cannot guarantee that every Slack surface will accept or render a payload the same way.

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

If posting fails, keep that exact prepared envelope instead of converting again. The failed-post guidance explains the difference between a definite rejection and an ambiguous network 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 an Error whose message lists 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 why capability profiles can be used to avoid newer block forms.

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
Configured renderer returns bytes without uploader or later recoveryRejects terminal ConfigurationError
Invalid options or invalid collaborator shapeRejects typed ConfigurationError
Injected parser or plain-text renderer failsRejects typed ConfigurationError
Ordinary custom-adapter exceptionReturns a document fallback plus a degradation
Caller abort, timeout, or shutdownRejects typed operation error
Limit violation in validateBlocksThrows an Error with path-annotated issues

Configuration errors are programmer errors and reject on purpose. With the built-in pipeline and valid options, unsupported Markdown falls back rather than throwing. Extension paths can still expose missing upload configuration or propagate a typed configuration/operation error.

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

Published entry points can carry separate copies of the error classes in bundled output, so instanceof can fail across subpaths. The exported guards work across those boundaries; after a guard narrows the error, branch on code rather than testing a subclass.