| name | typescript-strict |
| description | Rust-style strict TypeScript discipline for silkdown — branded types, exhaustive discriminated unions over enums, `assertNever` exhaustiveness checks, `as const` and `satisfies` for literal preservation, parse-don't-validate at boundaries, Result-style explicit errors over throws where it pays off, and type-level tests. Auto-trigger when adding or modifying types in packages/*/src/**/*.ts, when introducing a switch on `node.name` or any other discriminated union, when defining or extending the public API in packages/*/src/index.ts, when validating untrusted input (URL policy, options objects, parse callbacks), when writing `as` casts, or when reviewing PRs for `any`/unchecked `unknown`. Also fires on: "branded type", "newtype", "discriminated union", "exhaustive", "assertNever", "Result type", "neverthrow", "ts-pattern", "ts-reset", "type guard", "satisfies", "as const", "type-level test", "expect-type". |
TypeScript Strict — silkdown edition
Rust-style strict TypeScript discipline for this repo. Layers on top of the
working rules in AGENTS.md (no any, no broad unknown, no unchecked casts;
shared explicit types in package-local types.ts; don't hide invalid states
with broad fallback logic; validate unknown input at boundaries).
Core principles
- Make illegal states unrepresentable. If a value cannot be both
loading and
data, don't model it that way. Use discriminated unions, not parallel
booleans.
- Push uncertainty to the edge. Untrusted input is
unknown until parsed.
Inside the system, every variable has a precise type. No defensive re-checks
in inner code — that means a boundary leaked.
- Exhaustiveness is a load-bearing feature. When a new node type, decoration
kind, or option is added, the compiler must point at every site that needs to
handle it. Switches without
assertNever (or .exhaustive()) silently drift.
- Never lie to the compiler.
as casts disable safety. as const and
narrowing assertions against unknown are the only acceptable forms. If you
reach for as Foo ask: what shape is wrong, and how do I model the real one?
Pattern 1 — Discriminated unions over flags and enums
@lezer/markdown node names already form a string union (e.g. "ATXHeading1" | "Emphasis" | "StrongEmphasis"). Mirror that pattern for any new domain entity.
Prefer string-literal unions over TypeScript enum (enums emit runtime objects,
break tree-shaking, and have nominal-vs-structural footguns).
type Decoration = {
isHidden: boolean;
isMarker: boolean;
isWidget: boolean;
};
type Decoration =
| { kind: "hide"; from: number; to: number }
| { kind: "mark"; from: number; to: number; className: string }
| { kind: "widget"; from: number; to: number; widget: WidgetType };
The discriminant (kind, type, tag, _tag) is the field every consumer
switches on. Keep its name consistent across a module.
Pattern 2 — Exhaustive switches with assertNever
Every switch on a discriminated union ends with an assertNever default. Adding
a new variant becomes a compile error at every consumer.
function neverReached(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function describe(node: Decoration): string {
switch (node.kind) {
case "hide": return `hide ${node.from}..${node.to}`;
case "mark": return `mark ${node.className}`;
case "widget": return `widget ${node.widget.constructor.name}`;
default: return neverReached(node);
}
}
For node-name dispatch in packages/core/src/plugin.ts, the existing switch (node.name) on Lezer node names is a discriminated union over string literals.
A trailing assertNever-style guard is impractical (Lezer's union is open), but
the same principle applies: a default branch that logs and returns rather than
silently doing nothing.
Optional: ts-pattern for complex matches
For non-trivial dispatch (matching on multiple fields, nested shapes, guards),
ts-pattern (~900K weekly downloads, v5.9 as of late 2025) gives Rust-style
match/with/exhaustive with full type narrowing:
import { match, P } from "ts-pattern";
const summary = match(decoration)
.with({ kind: "hide" }, (d) => `hide ${d.from}-${d.to}`)
.with({ kind: "mark", className: P.string }, (d) => `mark ${d.className}`)
.with({ kind: "widget" }, (d) => "widget")
.exhaustive();
Adopt ts-pattern only when: the switch has 5+ cases or matches on multiple
fields. For 2-4 simple variants, a plain switch + assertNever is lighter and
the project has no runtime dependency yet.
Pattern 3 — Branded (newtype) types for primitives
A number that means a CodeMirror document position is not the same kind of
number as a line number. The compiler can enforce this with a brand:
type DocPosition = number & { readonly __brand: "DocPosition" };
type LineNumber = number & { readonly __brand: "LineNumber" };
function asDocPosition(n: number): DocPosition {
return n as DocPosition;
}
Construct branded values only at boundaries (parsing, conversion). Inside, the
compiler refuses to mix them.
Where this earns its keep in silkdown: positions returned by node.from /
node.to vs state.doc.line(...).from (line-anchored) vs character offsets
within a slice. Mixing them silently has caused historic CM6 bugs.
Where it's overkill: simple numeric IDs in code that's only used in one
place.
Pattern 4 — Result-style errors at trust boundaries
silkdown's URL policy already returns string | null (truthy = accepted, null =
rejected). That's the simplest Result form: T | null where the contract is
"caller must handle null."
For a richer story (multiple error reasons, chained operations), the
neverthrow library (v8.2 as of Feb 2025, actively maintained) gives Rust's
Result<T, E> with .map(), .mapErr(), .andThen(), .match():
import { ok, err, Result } from "neverthrow";
type ParseError = { kind: "empty" } | { kind: "scheme-blocked"; scheme: string };
function parseUrl(input: string): Result<string, ParseError> {
const trimmed = input.trim();
if (trimmed.length === 0) return err({ kind: "empty" });
if (trimmed.startsWith("javascript:")) return err({ kind: "scheme-blocked", scheme: "javascript" });
return ok(trimmed);
}
Adopt neverthrow only when: error reasons are part of the contract that
callers must distinguish. For "valid or invalid", string | null (the existing
pattern) is lighter and dependency-free.
Don't adopt Effect-ts: it reshapes the entire codebase. Out of scope for a
CM6 extension library.
Pattern 5 — as const and satisfies
as const preserves literal types. satisfies checks a value matches a shape
without widening the inferred type.
const NODE_NAMES = ["ATXHeading1", "Emphasis", "StrongEmphasis"] as const;
type NodeName = (typeof NODE_NAMES)[number];
const HIDE_DECO = Decoration.replace({}) satisfies Decoration;
Use these instead of explicit : annotations whenever you want both validation
and a precise inferred type.
Pattern 6 — Parse, don't validate, at boundaries
Untrusted inputs to silkdown:
- The
value prop of <SilkdownEditor> (a string — assumed valid markdown, but treat as-is).
- User-supplied options to
silkdown(opts: SilkdownOptions = {}).
- The
renderMath callback's return value (consumer-provided HTMLElement).
- URLs passed to
safeUrl and the UrlPolicy.
- Future: frontmatter parsers, custom parser extensions.
For each: parse into a precise type at the boundary, throw or return a typed
error if invalid, and let internal code trust the result. Do not re-validate
in inner code.
For complex shapes (e.g. a future plugin manifest), reach for zod or valibot
— but only if the shape justifies a runtime schema. For 2-3 fields, a hand-typed
guard is lighter:
function isValidOptions(o: unknown): o is SilkdownOptions {
if (typeof o !== "object" || o === null) return false;
return true;
}
Pattern 7 — ts-reset for default-API papercuts
@total-typescript/ts-reset (v0.4+) overrides several too-loose JS API types:
| API | Default | After ts-reset |
|---|
JSON.parse(...) | any | unknown |
await response.json() | any | unknown |
arr.filter(Boolean) | (T | undefined | null | 0 | "")[] | T[] |
arr.includes(x) | strict mismatch | accepts string widened |
Adopt for silkdown when: the project starts handling JSON (e.g. frontmatter
metadata, persisted user options, network-fetched math/mermaid renderings). For
the current state (no JSON parsing in core), it's not worth the dependency.
If adopted, add to a single import "@total-typescript/ts-reset"; at the top of
packages/core/src/index.ts and document it in CLAUDE.md.
Pattern 8 — Type-level tests with expect-type
Public API surface (packages/api-snapshot/src/consumer.ts) deserves type-level
assertions, not just runtime tests. expect-type gives compile-time assertions:
import { expectTypeOf } from "expect-type";
import { silkdown, type SilkdownOptions } from "@silkdown/core";
expectTypeOf(silkdown).parameters.toEqualTypeOf<[SilkdownOptions?]>();
expectTypeOf<SilkdownOptions["gfm"]>().toEqualTypeOf<boolean | undefined>();
Add to api-snapshot when changing exported types — these assertions catch
breaking changes that runtime tests miss (e.g. a parameter going from optional
to required).
Anti-patterns (auto-flag in PR review)
as Foo casts: model the shape, don't lie to the compiler. Acceptable forms:
as const, narrowing against unknown after a guard.
any anywhere outside a documented // eslint-disable with a specific
reason.
unknown propagated more than one call deep without narrowing.
- Switches without a
default branch, especially on string-literal unions.
- Type assertions in places that should parse:
JSON.parse(s) as Config is a
smell unless preceded by a schema check.
- Boolean flag parameters that change control flow — split into two functions
instead. Boolean data (e.g. an
isActive: true field stored in state) is
fine; boolean behavior switches in a signature are not.
- Discriminant fields with inconsistent names (
kind here, type there, _tag
somewhere else) within one module.
throw "string" — always throw an Error (or subclass) so callers get a
stack trace and don't have to type-narrow the catch parameter.
Recommended dependencies (when justified)
| Library | Add when | Don't add for |
|---|
ts-pattern | 5+ case match or multi-field dispatch in a hot path | 2-3 case switches |
neverthrow | Multiple distinguishable error reasons in a contract | Single "valid or null" decisions |
@total-typescript/ts-reset | Project starts parsing JSON or handling untyped JS APIs | Pure TS code with no JSON.parse |
expect-type | Adding type-level assertions to api-snapshot | Runtime test files |
zod / valibot | Runtime validation of complex external shapes (3+ fields, nested) | 1-2 field options objects |
All five are tree-shakable and have minimal runtime impact (ts-reset is types-only).
Reference
AGENTS.md (project root) — working rules: no any, no broad unknown,
validate at boundaries, treat CodeMirror packages as peer/runtime boundaries.
docs/architecture.md — domain vocabulary: Decoration kinds, ViewPlugin vs
StateField, atomicRanges, the Live Preview algorithm.
packages/api-snapshot/src/consumer.ts — public-API gate; type-level tests
belong here.
ts-pattern (npm) — exhaustive pattern matching with .with() / .exhaustive().
neverthrow (npm) — Result<T, E> with .map() / .mapErr() / .andThen() / .match().
@total-typescript/ts-reset (npm) — types-only override pack that retypes JSON.parse / Array.filter(Boolean) / .includes() more usefully.
expect-type (npm) — compile-time type assertions like expectTypeOf(x).toEqualTypeOf<T>().