Making your own data type

Most of the time the built-in types — Array, DataFrame, Series, Text, Artifact — are enough. Reach for a custom type when your data has structure or metadata the generic types do not capture, and you want the canvas to treat it as its own thing: type-check connections, pick a tailored preview, and carry domain metadata from block to block.

A custom type is a small subclass of a core type. You are not building a new storage format — you inherit all of that — you are naming your data and pinning the rules and metadata that go with it.

This page covers a simple, project-local type. Designing the type system for a distributable package (multiple types, composites, previewers) is covered in the Package Development guide (docs/package-development/types.md).

Pick a base type

Subclass the core type whose in-memory shape matches your data:

Your data is… Subclass
An N-dimensional array (image, volume, stack) Array
A 1-D indexed signal (spectrum, chromatogram, time series) Series
A table DataFrame
A bundle of named sub-objects CompositeData

You inherit to_memory(), to_pandas()/to_numpy(), sel(), with_meta(), and the data= constructor for free. Do not redefine to_pandas or to_numpy — you already have them.

Example: a microscope image type

Say you work with grayscale microscope images and you want to (a) guarantee every instance is 2-D with y and x axes, and (b) carry the pixel size so downstream blocks can compute real distances. Subclass Array:

from typing import ClassVar

from pydantic import BaseModel, ConfigDict
from scistudio.core.types import Array


class MicroscopeImage(Array):
    """A 2-D grayscale microscope image with a known pixel size."""

    # Tighten the axis schema: every instance MUST be 2-D (y, x).
    required_axes: ClassVar[frozenset[str]] = frozenset({"y", "x"})
    allowed_axes: ClassVar[frozenset[str] | None] = frozenset({"y", "x"})
    canonical_order: ClassVar[tuple[str, ...]] = ("y", "x")

    # Typed, validated metadata that travels with the image.
    class Meta(BaseModel):
        model_config = ConfigDict(frozen=True)   # frozen so updates stay immutable
        pixel_size_um: float | None = None       # microns per pixel
        objective: str | None = None

Two things make this a real type and not just an Array:

  • The axis schema (required_axes / allowed_axes / canonical_order) rejects anything that is not a 2-D y, x image at construction time, so a malformed image cannot flow into your workflow unnoticed.
  • The Meta model is a frozen Pydantic model. Its fields are validated, and because it is frozen, with_meta() returns a new instance instead of mutating in place — which keeps lineage honest.

Construct and read it

You build a custom type exactly like a core type — the keyword constructor with data= — and you set metadata through the Meta model:

import numpy as np

img = MicroscopeImage(
    axes=["y", "x"],
    data=np.zeros((512, 512), dtype="uint16"),
    meta=MicroscopeImage.Meta(pixel_size_um=0.65, objective="40x"),
)

pixels = img.to_memory()          # numpy ndarray, exactly like Array
size = img.meta.pixel_size_um     # 0.65

To update metadata, use with_meta() (inherited); it returns a new instance:

img2 = img.with_meta(objective="63x")

Use it on a port

Name your type on a port and blocks will only connect compatible wires:

input_ports = [InputPort(name="image", accepted_types=[MicroscopeImage])]

Because MicroscopeImage is an Array, a port that accepts Array will also accept your image — subtypes flow into supertype ports, not the other way around.

Where it goes

Put the class in a .py file under your project (next to the blocks that use it). For a one-off project that is all you need. If you later want to share the type — give it a dedicated previewer, publish it for others to install — see the Package Development guide, which uses the real Spectrum type from the spectroscopy package as its worked example.

What you inherited (and must not re-create)

Inherited from the core type Do not write your own
to_memory() — the canonical in-memory form
to_pandas() / to_numpy() — ergonomic read-out ✗ never redefine these
with_meta(**changes) — immutable metadata update
sel(...) / slice(...) / iter_chunks(...) — large-data reads
the data= keyword constructor

See the core type in the API reference for the full list.