docs/solutions/developer-experience/2026-04-26-slate-v2-value-generics-should-be-public-boundary-not-runtime-variance.md
Slate v2 replaced declaration merging with Value-first generics, but some
public APIs still erased the concrete V extends Value. The first attempted fix
made internal instance callbacks too generic and TypeScript correctly turned the
runtime into an invariant mess.
Editor.getChildren(editor) preserved the custom value shape, but operations,
commits, extension listeners, and React selectors still collapsed toward base
Value.Operation<V>, EditorCommit<V>, and EditorTransaction<V> to every
instance method made calls like someHelper(editor) fail because
Editor<CustomValue> was no longer assignable to unparameterized Editor.Value is assignable to the constraint of type V, but V could be instantiated with a different subtype.V through every runtime instance callback.BaseEditor as the only generic enforcement point.Keep exact generics at public and static API boundaries, and keep runtime instance parameters structurally broad where TypeScript variance would otherwise poison internal helpers.
Good boundary:
const children: CustomValue = Editor.getChildren(editor)
const operations: readonly Operation<CustomValue>[] =
Editor.getOperations(editor)
const commit: EditorCommit<CustomValue> | null = Editor.getLastCommit(editor)
Good extension contract:
defineEditorExtension<typeof editor>({
name: 'generic-extension',
operationMiddlewares: [
(context, next) => {
const operation: Operation<CustomValue> = context.operation
next(operation)
},
],
commitListeners: [
(commit, snapshot) => {
const children: CustomValue = snapshot.children
void commit
void children
},
],
})
Runtime internals can stay broad:
withTransaction: (fn: (transaction: EditorTransaction<any>) => void) => void
subscribe: (listener: SnapshotListener<any>) => () => void
The public static wrapper casts at the boundary after the runtime has done its structural work:
withTransaction<V extends Value>(
editor: Editor<V>,
fn: (transaction: EditorTransaction<V>) => void
) {
editor.withTransaction(fn as (transaction: EditorTransaction<any>) => void)
}
TypeScript function parameters are contravariant under strict checking. If an
internal editor instance says it only accepts Operation<CustomValue>, it cannot
also be used where a helper expects an editor that accepts base Operation<Value>.
The runtime does not need exact app value types to mutate weak maps, reconcile snapshots, or dispatch transactions. Consumers do need exact app value types at the public API boundary. That is where the generic contract belongs.
Editor<V> stops flowing into internal helpers, check for V in parameter
positions before weakening the public API.ValueOf<typeof editor>, Operation<V>, EditorCommit<V>, and
EditorSnapshot<V> precise at public boundaries.WithEditorFirstArg and unparameterized Editor aliases as likely
generic erasure points.