Skip to content

Parsing

Everything else in this package trusts its types. Parsing is the one place that checks. It takes an unknown value, such as the result of JSON.parse, and returns a typed value or a ParseError. It never throws, and it needs no schema library.

What parseShape checks

parseShape(input) returns a Result<Shape, ParseError>. The input must be an object with these fields, checked in this order:

Field Rule
id A non-empty string, branded as a ShapeId
frame x, y and rotation finite. width and height finite and 0 or more
drawable An object with a known kind and that kind’s fields
index A string that parseZIndex accepts
style An object, checked as described below
groupIds Optional. An array of non-empty strings, each a GroupId. Absent means []

rotation is any finite number. It is not normalised. A number must be a number type and finite, so "1", NaN and Infinity are rejected.

Each drawable kind has its own fields, checked in the order shown:

Kind Fields
rectangle cornerRadius finite and 0 or more, optional label
ellipse optional label
diamond optional label
line points, an array of at least 2 points
freehand samples, an array of at least 1 sample, each a point and a pressure from 0 to 1
text text any string, empty allowed. sizing of "auto" or "fixed-width". fontSize above 0. fontFamily of "hand-drawn", "sans" or "mono". Optional align of "left", "center" or "right", "left" when absent
arrow start and end points, bend any finite number, and startHead and endHead, each "none", "arrow", "triangle", "dot" or "bar"

A label is an object with a text string, a fontSize above 0 a fontFamily as for text and an optional align, "center" when absent, so files saved before alignment still parse. An align that is not one of the three is an unknown-value error. A missing label means no text inside the shape, so files saved before labels still parse.

What parseStyle checks

style is an object whose fields are checked in this order:

Field Rule
stroke color as below, width above 0, style of "solid", "dashed" or "dotted"
fill kind of "none", "hachure", "cross-hatch" or "solid", and for any kind but "none" a color
roughness Finite and 0 or more
opacity Finite, from 0 to 1
seed An integer from 0 to 2^32 - 1

A colour is a string of # and six hex digits, in any case. Short forms such as #fff and names such as red are rejected with invalid-color. A none fill drops any color it carries.

A point is an object with finite x and y. Every element of an array is checked before its length, so a bad point is reported ahead of too-few.

What parseBinding checks

parseBinding(input) returns a Result<Binding, ParseError>. The fields, in order:

Field Rule
id A non-empty string, branded as a BindingId
arrowId A non-empty string, branded as a ShapeId
terminal "start" or "end"
targetId A non-empty string, branded as a ShapeId
anchor An object with x and y, each finite and from 0 to 1

An anchor outside 0 to 1 is rejected with out-of-range, not clamped. normalizedPoint clamps for callers that compute an anchor from a pointer. A stored anchor outside the range means the data is corrupt, and clamping would hide that. Whether the ids name real shapes is not checked here, because a binding alone cannot know. parseShapeDocument checks that, once it has the shapes.

What parseShapeDocument checks

A ShapeDocument is the shapes and the bindings that connect them:

type ShapeDocument = {
readonly shapes: readonly Shape[];
readonly bindings: readonly Binding[];
};

parseShapeDocument(input) returns a Result<ShapeDocument, ParseError>. Both shapes and bindings are required arrays. It parses every shape, then every binding, and keeps the input order. Paths include the array position, such as ["shapes", 3, "frame", "width"] or ["bindings", 0, "targetId"].

When every record parses, it checks that the records agree with each other:

Reason Path points at Meaning
duplicate-id the second id Two shapes share an id, or two bindings share an id
unknown-shape arrowId or targetId The id is not a shape in the document
not-an-arrow arrowId The binding’s arrow is a shape of another kind
target-is-arrow targetId The target is an arrow, which includes the arrow itself
duplicate-terminal the second terminal Two bindings attach the same end of the same arrow

Shape ids are checked first, then each binding in order. Tied z indices are allowed, because compareShapes breaks ties by id.

resolveArrow tolerates a missing target at runtime. A document never has one, because deleting a shape goes through removeBindingsFor. A file that does is corrupt, so parsing rejects it.

One bad record rejects the whole document. There is no partial recovery. A function that loads what it can may be added later for an import screen that wants it.

The document has no version field. Persistence is out of scope here. The app’s file format wraps the document and owns its versioning.

Parsing a paste

docs/examples/parse-pasted-shape.ts
import { parseShape } from "@draw/shapes";
import type { ParseError, Shape } from "@draw/shapes";
const pasted = `{
"id": "box-1",
"frame": { "x": 10, "y": 20, "width": -40, "height": 30, "rotation": 0 },
"drawable": { "kind": "rectangle", "cornerRadius": 4 },
"index": "a0",
"style": {
"stroke": { "color": "#1e1e1e", "width": 2, "style": "solid" },
"fill": { "kind": "none" },
"roughness": 1,
"opacity": 1,
"seed": 7
}
}`;
const result = parseShape(JSON.parse(pasted));
export const shape: Shape | undefined = result.ok ? result.value : undefined;
export const error: ParseError | undefined = result.ok ? undefined : result.error;

The pasted rectangle has a width of -40. parseShape returns { path: ["frame", "width"], reason: "negative" } and no shape, so the app can point at the field. JSON.parse itself throws on text that is not JSON. The app handles that before it calls the parser, and the parser only ever sees values that are already data.

The error

type ParseError = {
readonly path: readonly (string | number)[];
readonly reason: ParseReason;
};

path points at the offending value, relative to the input you passed. For a shape it is ["frame", "width"]. Array positions are numbers.

Reason The value at path is
not-object not an object, or is an array or null
not-array not an array, where a list such as points, samples or shapes is needed
missing absent, or undefined
not-string not a string
empty-string an empty string where an id is needed
not-number not a number
not-finite NaN or infinite
negative below 0 where 0 or more is needed
not-positive 0 or below where more than 0 is needed, as in a stroke width or fontSize
unknown-kind a drawable kind this package does not have
unknown-value not one of the allowed strings, such as a fill kind
not-integer a number with a fraction where an integer is needed, as in seed
invalid-color a string that is not # and six hex digits
out-of-range a number outside its range, such as an opacity above 1 or a seed above 2^32 - 1
too-few an array with too few elements, such as a line with fewer than 2 points
invalid-index a string that is not a valid z-index

ParseReason is a closed union, so a switch over it is checked for completeness.

Decisions

  • First error wins. Parsing stops at the first problem and returns one error. Fields are checked in the order of the type definitions, so the same input always gives the same error.
  • Unknown fields are dropped. The output is built from the known fields only. A file written by a newer version still loads, and extra data never leaks into a shape.
  • Nothing is repaired. A bad value is rejected, never clamped or defaulted, because repairing would hide corrupt data.