Skip to main content

Block kinds

What a kind is

A block kind is a small, separately versioned package a block owns beside its model, workflow and UI. It declares the block's BlockParams — the typed contract for initializing the block programmatically — plus a runtime check of that contract, so a kind ships executable code, not only types. A kind changes far more slowly than its block: many block versions implement one kind version, and the version covers params and behavior, so a changed default that alters results bumps it even when BlockParams stays the same.

It exists because cross-block discovery in Platforma is purely structural. A block finds another block's output by describing it — PColumn spec name, axes, annotation predicates — and nothing in that mechanism carries a name for what a block is. A kind adds that nominal identity: a block declares its kind, and something that has never seen the block's internals can name the kind and hand it params.

The thing that names kinds is a project template.

The kind package

Every block declares exactly one sibling kind/ package. The structurer enforces this: a block with no kind fails with declares no kind — every block must have a sibling kind/ package, and a block with two or more fails just as loudly. block-tools structure init creates the package for every new block.

The package holds five files:

kind/
├── package.json # structurer-managed
├── tsconfig.json # structurer-managed
├── .oxlintrc.json # structurer-managed
├── .oxfmtrc.json # structurer-managed
└── src/index.ts # yours

The first four are structurer-owned — structure refresh rewrites them and structure check reports drift in them. src/index.ts is seeded once by structure init and never touched again: the params contract is the block author's.

The load-bearing part of the generated package.json:

kind/package.json
{
"name": "@platforma-open/milaboratories.clonotype-clustering.kind",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"sources": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"scripts": {
"fmt": "ts-builder format",
"watch": "ts-builder build --target block-kind --watch",
"build": "ts-builder build --target block-kind && block-tools build-kind-manifest",
"check": "ts-builder check --target block-kind"
},
"dependencies": {
"@platforma-sdk/block-kind": "catalog:"
}
}

The name is always the block's facade name plus .kind.

info

Two details of that file are worth knowing.

All three of import, require and default are spelled out because block-tools build-model loads the model through require(), and the model requires its kind — so the CJS arm has to resolve to real CommonJS.

A kind builds twice. dist/index.js and dist/index.cjs are built with dependencies external — that pair is what the block imports, so a kind and the block implementing it share one copy of anything they both depend on. dist/kind.js is a second, self-contained bundle; it is what the registry publishes and what block-tools build-kind-manifest hashes, and it is deliberately not an entry in exports.

A kind package is private: true and is never published to npm. Its content ships to the block registry's kinds/ tree. The only npm-published package involved is the SDK's own @platforma-sdk/block-kind.

Writing a kind

src/index.ts declares the type, the runtime check, and the kind object.

kind/src/index.ts
import type { PlRef } from "@platforma-sdk/model";
import { isPlRef } from "@platforma-sdk/model";
import { assertParamsObject, defineBlockKind } from "@platforma-sdk/block-kind";
import { name, version } from "../package.json" with { type: "json" };

export type BlockParams = { sources?: PlRef[] };

function parseInitializationParams(value: unknown): BlockParams {
assertParamsObject(value);

const { sources } = value;
if (sources !== undefined && !(Array.isArray(sources) && sources.every(isPlRef))) {
throw new Error("'sources' must be an array of references to upstream columns.");
}

return { sources };
}

export const kind = defineBlockKind<BlockParams>({
name,
version,
parseInitializationParams,
});

Note import { name, version } from "../package.json" with { type: "json" };. Identity comes from the package's own package.json rather than from hand-typed literals, so the on-wire {name}@{version} reference cannot drift from what the manifest records. The bundler inlines the JSON import — tree-shaken down to the two strings — so nothing has to be injected at build time.

A block that takes no author-supplied params still declares a kind. Its contract is Record<string, never> and its parser returns {} — deliberately chosen, not defaulted into.

Checking the params

parseInitializationParams receives whatever a template file supplied: unknown, not BlockParams. assertParamsObject establishes that it is a plain object, and the parser's return type holds the rest to the contract. The SDK ships no validation library, so the checks are plain TypeScript.

One rule outranks the shape of that code: never hand-restate the shape of an SDK value. Anything pointing outside the block — a reference to another block's output, a column identifier — has a guard exported alongside its type from @platforma-sdk/model, and that guard is the check:

ValueGuard
PlRef — a reference to another block's outputisPlRef
ColumnUniversalId — a column identifier in its serialized string formisColumnUniversalId
ColumnUniversalKey — the same identifier parsed into an objectisColumnUniversalKey
GlobalPObjectId — a result-pool object idisGlobalPObjectId

The list is not closed: SDK types that can appear in params generally travel with an is… guard, so look for one before writing a shape check by hand.

Two reasons this matters. A restated check is what lets through a reference missing the __isRef: true marker the block dependency tree is rebuilt from — the block ends up wired to nothing, with no error to point at. And a copied shape check goes stale silently: the real guard moves with its type, a restatement does not.

For contracts past a handful of fields, real blocks stop writing the checks inline and build a table of per-field guards instead, closed with a satisfies clause so the table cannot omit a field the type declares. blocks/clonotype-clustering/kind/ is the precedent worth copying.

What belongs in BlockParams

View state stays out. The table's grid state, graph states, an alignment widget's model — that is what the user is looking at, not the recipe a project template exists to reproduce.

Every field is generally optional. A half-configured block — no dataset picked, no columns selected — is an ordinary state the UI reaches, so export must be able to write it and apply must be able to take it back. Whether a configuration is runnable is settled by the model's args lambda, not by the kind.

Do not re-check numeric ranges the UI already bounds. A threshold outside [0, 1] is a configuration the block itself has to answer for, and the ranges live with the sliders that set them. Restating them in the kind makes it refuse a file the UI can produce the moment either side moves.

A key the kind does not declare is dropped, not refused. A parser returns the params to use, so a field it never read never reaches the block. Refusing on top of that would mean every kind restating its own field list as strings, with nothing keeping that list in step with the type — and a field added to the contract but missed in the list would start refusing files that are correct. What an unexpected key usually means is params written against a different version of the kind, and that is guarded by the version in the entry's {name}@{selector} reference.

API reference

The public surface of @platforma-sdk/block-kind is two functions and three types: defineBlockKind, assertParamsObject, and CompiledBlockKind / InferBlockParams / BlockKindMeta.

defineBlockKind

function defineBlockKind<BlockParams>(
meta: BlockKindMeta<BlockParams>,
): CompiledBlockKind<BlockParams>;

BlockKindMeta has three fields:

FieldTypeMeaning
namestringThe full npm package name of the kind, exactly as a project-template entry writes it (e.g. @platforma-open/milaboratories.mixcr-clonotyping-2.kind). Import it from the package's own package.json.
versionstringThe kind package's own version, imported the same way.
parseInitializationParams(value: unknown) => BlockParamsThe kind's runtime check. Required.

defineBlockKind validates nothing itself. It returns a frozen { kindSchema: "v1", name, version, parseInitializationParams }. Nothing serializes that object: only the name and version are read, to compose the {name}@{version} reference, and the parser is called in place.

The declared BlockParams is also pinned by a contravariant phantom slot, which under strictFunctionTypes blocks silent structural widening between kinds of different param shapes — a kind typed for { ref: PlRef; k: number } is not assignable to one typed for { ref: PlRef }. The slot carries zero runtime bytes.

parseInitializationParams

Required. Without it, a bad value is caught nowhere: params typed number[] arriving as ["3","1","2"] reach the block's init and the workflow with no error anywhere, and a numeric sort silently becomes a lexicographic one.

Two obligations:

  • It must throw to reject.
  • It must return the params to use. The returned value, not the input, is what the block receives — which is what lets a parser strip keys the kind does not declare and coerce what it chooses to coerce.

TypeScript checks the parser against the declared type, since it must return BlockParams, so a check that forgets a required field does not compile. The reverse — a check that accepts less than the type allows — is not caught, and neither is a cast.

warning

value as BlockParams satisfies the signature and verifies nothing. It is the one way to hold this slot open and get no value from it.

Params reach the parser with references already in live PlRef form. On the pre-flight check, which happens before any block exists, the reference ids are placeholders — so a parser must not treat a specific id as meaningful.

assertParamsObject

function assertParamsObject(value: unknown): asserts value is Record<string, unknown>;

The half of a params check every kind needs and no two kinds differ on: establish that the value is an object whose fields can be read. Worth a shared function because it is easy to get wrong by hand — typeof null is "object", Object.keys(5) is [] and Object.keys(["a"]) is ["0"], so a naive test lets null, a number and an array through as if they were empty params.

{} passes, and so does an object carrying keys the kind does not declare. Everything else is rejected with a finished sentence addressed to whoever wrote the file:

InputMessage
nullParams must be an object, not null.
[]Params must be an object, not an array.
"{}"Params must be an object, not a string ("{}").
5Params must be an object, not a number (5).
undefinedParams must be an object, not nothing.

A primitive is printed, not just typed, because params: "{}" in a file is a quoting mistake that is only obvious when the value itself is shown.

Wiring the kind into the model

The kind is passed twice — once to DataModelBuilder, which flows BlockParams into init, and once to BlockModelV3.create.

model/src/index.ts
import { BlockModelV3, DataModelBuilder } from "@platforma-sdk/model";
import { kind } from "@platforma-open/<org>.<block>.kind";

// `params` is optional — a block may be created without a template supplying
// them — so every kind-declared field keeps a fallback default.
const dataModel = new DataModelBuilder({ kind })
.from<BlockData>("v1")
.init(({ params }) => ({ sources: params?.sources ?? undefined }));

export const platforma = BlockModelV3.create({ dataModel, kind })
.args<BlockData>((data) => {
if (data.sources === undefined || data.sources.length === 0) {
throw new Error("Sources are required");
}
return { sources: data.sources };
})
.templateParams((data) => ({ sources: data.sources }))
.done();

Three things to get right:

  • params is undefined when a block is created through the UI. Only a project template or a test supplies them, so every kind-declared field needs a fallback default in init.
  • It must be the same kind object in both calls. Nothing ties them together at the type level — they arrive as two separately-passed objects — so create() guards it at runtime and throws Block kind mismatch: data model built for '…' but create() got '…'.
  • templateParams() is required. done() throws templateParams() not set. without it. A block whose state carries nothing worth restoring returns {}.

templateParams is documented with the rest of the builder in the block model reference.

Adding a kind to an existing block

  1. Upgrade the SDK and run block-tools structure refresh. The refresh creates kind/, adds the kind to the model's dependencies and to the facade's devDependencies (both as workspace:*). Add kind to the block's pnpm-workspace.yaml packages: list by hand — the structurer needs that member to discover the package.

  2. Replace the scaffold's sentinels. structure init seeds src/index.ts with two deliberate failures:

    kind/src/index.ts (as seeded)
    export type BlockParams = NEEDS_BLOCK_PARAMS;

    function parseInitializationParams(value: unknown): BlockParams {
    assertParamsObject(value);

    return {};
    }
    warning

    The scaffold does not compile. NEEDS_BLOCK_PARAMS is an undefined type (TS2304), so a scaffolded-but-unmigrated block fails to typecheck until the contract is chosen on purpose. A block with no author-supplied params gets Record<string, never> — deliberately, not by default. The return {} is the second sentinel: it stops compiling the moment the contract declares a required field, so the check cannot drift from the contract by being left behind.

  3. Move the model over. new DataModelBuilder({ kind }), BlockModelV3.create({ dataModel, kind }), and add .templateParams(…).

  4. Add a changeset bumping the kind, the model and the facade.

info

Read the structure refresh diff before committing it — it rewrites structurer-owned files wholesale. In clonotype-clustering it stripped @types/node from the test scope (breaking a test that shells out through node:child_process) and removed --unstable from the facade's prepublishOnly.

See also: project templates and the block model.