Files
v10/dist/dev/core/composition/create-composition.d.ts
T

273 lines
14 KiB
TypeScript

import { ReadonlySignal, Signal } from "../signals/primitives.js";
//#region src/core/composition/create-composition.d.ts
/**
* Cleanup returned by a behavior. Behaviors may return:
* - `void` / `undefined` — no cleanup needed
* - A function — called on destroy (may return a Promise)
* - An object with `destroy()` — called on destroy (may return a Promise)
*/
type BehaviorCleanup = void | (() => void | Promise<void>) | {
destroy(): void | Promise<void>;
};
/**
* A signal map keyed by the fields of `S`. Each field is a writable signal.
*
* Optional fields on `S` map to required signal slots whose value type
* includes `undefined`, ensuring every key has a signal even when the
* underlying value is absent.
*
* Used in two roles:
* - Engine-side **construction**: `Composition<S, C>` exposes its public
* surface as `StateSignals<S>` (everything writable) so external code
* can read or write any slot.
* - Behavior **input convenience**: a behavior that writes to every slot
* can type its setup state param as `StateSignals<{ ... }>` rather than
* spelling out per-slot `Signal<T>` types.
*
* Behaviors that mix read-only and writable slots type the setup param
* directly as a slot map (`{ x: Signal<T>; y: ReadonlySignal<U> }`)
* instead of going through `StateSignals<>`.
*/
type StateSignals<S extends object> = { [K in keyof S]-?: Signal<S[K]>; };
/**
* A signal map keyed by the fields of `C`. Each field is a writable signal
* for a platform object or actor reference. Same dual role as
* `StateSignals<S>` — see its docblock.
*/
type ContextSignals<C extends object> = { [K in keyof C]-?: Signal<C[K]>; };
/**
* Slot-map shape — a record where each value is at least a `ReadonlySignal`.
* `Signal<T>` is structurally a subtype of `ReadonlySignal<T>` (it adds
* `.set()`), so a writable slot satisfies this bound too.
*
* This is the bound used for behavior `state` / `context` slot maps. It
* lets a single behavior declare a *heterogeneous* slot map where some
* slots are `Signal<T>` (writable) and others are `ReadonlySignal<T>`
* (read-only) — making read/write intent explicit at the call site and
* giving body-level enforcement (TS rejects `.set()` on a read-only slot).
*/
type AnySlotMap = Record<PropertyKey, ReadonlySignal<unknown>>;
/**
* The deps object passed to each behavior by the composition.
*
* - `state` — slot map for state fields (reactive data). Per-slot read/
* write intent expressed via `Signal<T>` vs `ReadonlySignal<T>`.
* - `context` — slot map for platform objects and actor references.
* - `config` — static configuration, passed once at composition creation.
*/
interface BehaviorDeps<StateMap extends AnySlotMap, ContextMap extends AnySlotMap, Cfg extends object> {
state: StateMap;
context: ContextMap;
config: Cfg;
}
/**
* A behavior announces the state and context keys it needs alongside a
* `setup` function that receives deps (state, context, config) and
* returns an optional cleanup handle.
*
* The `stateKeys` / `contextKeys` declarations are the runtime expression
* of the behavior's contract — the caller (e.g. `createComposition`) uses
* them to know which signals to provide. The setup parameter type
* declares the *slot map* (per-slot `Signal<T>` vs `ReadonlySignal<T>`);
* together they form a complete contract.
*
* Manual `Behavior<>` literals (e.g. engine wrappers that forward keys
* from a wrapped behavior, or pass-through behaviors like `shareSignals`)
* opt out of exhaustiveness — the type alias is permissive (subset).
* Source behaviors should use `defineBehavior` to get exhaustiveness
* enforcement at the call site.
*/
interface Behavior<StateMap extends AnySlotMap = Empty, ContextMap extends AnySlotMap = Empty, Cfg extends object = Empty> {
/** State keys this behavior reads/writes. Subset of `keyof StateMap`. */
stateKeys: readonly (keyof StateMap)[];
/** Context keys this behavior reads/writes. Subset of `keyof ContextMap`. */
contextKeys: readonly (keyof ContextMap)[];
setup: (deps: BehaviorDeps<StateMap, ContextMap, Cfg>) => BehaviorCleanup;
}
/** A behavior with an unconstrained setup — used as a generic bound. */
type AnyBehavior = {
stateKeys: readonly PropertyKey[];
contextKeys: readonly PropertyKey[];
setup: (deps: any) => BehaviorCleanup;
};
/** Extract the deps type from a behavior's setup function. */
type DepsOf<B> = B extends {
setup: (deps: infer D, ...args: any[]) => any;
} ? D : never;
/**
* Empty-object fallback used when a behavior omits state, context, or config.
*
* Using `{}` rather than `object` is deliberate — `object & {x: T}` collapses
* to `{x: never}` under TS's union-to-intersection conversion in some inference
* contexts (likely a TS quirk around the `object` upper bound), whereas
* `{} & {x: T}` simplifies cleanly to `{x: T}`.
*/
type Empty = {};
/**
* Unwrap a signal map back to its state/context shape.
*
* Inferring through `{ get(): infer V }` rather than `Signal<infer V>`
* sidesteps `Signal`'s nominal/invariance behaviour — the conditional
* matches structurally on the read side, and `V` is inferred covariantly.
*/
type UnwrapSignals<M> = M extends object ? { [K in keyof M]: M[K] extends {
get(): infer V;
} ? V : never; } : Empty;
/** Infer the state shape a behavior requires from its deps parameter. */
type InferBehaviorState<F> = DepsOf<F> extends {
state: infer M;
} ? UnwrapSignals<M> : Empty;
/** Infer the context shape a behavior requires from its deps parameter. */
type InferBehaviorContext<F> = DepsOf<F> extends {
context: infer M;
} ? UnwrapSignals<M> : Empty;
/** Infer the config shape a behavior requires from its deps parameter. */
type InferBehaviorConfig<F> = DepsOf<F> extends {
config: infer C extends object;
} ? C : Empty;
/**
* Recursively intersect a per-behavior projection across the tuple.
*
* Iterating over the tuple directly avoids `UnionToIntersection`'s
* function-contravariance trick, which produces unstable intersections
* (collapsing concrete fields to `never` or unrelated types) when one of the
* union members is the empty `{}` fallback.
*/
type IntersectBehaviors<Behaviors extends readonly AnyBehavior[], Project extends object> = Behaviors extends readonly [infer First extends AnyBehavior, ...infer Rest extends readonly AnyBehavior[]] ? Apply<Project, First> & IntersectBehaviors<Rest, Project> : Empty;
/**
* Apply a projection (one of the marker types below) to a single behavior.
* Encoded as a discriminated dispatch so the recursion above can stay generic
* and we don't have to write three near-identical recursive types.
*/
type Apply<Project extends object, F> = Project extends {
kind: 'state';
} ? InferBehaviorState<F> : Project extends {
kind: 'context';
} ? InferBehaviorContext<F> : Project extends {
kind: 'config';
} ? InferBehaviorConfig<F> : never;
type StateProjection = {
kind: 'state';
};
type ContextProjection = {
kind: 'context';
};
type ConfigProjection = {
kind: 'config';
};
/** Resolve the combined state shape from an array of behaviors (intersection of all requirements). */
type ResolveBehaviorState<Behaviors extends readonly AnyBehavior[]> = IntersectBehaviors<Behaviors, StateProjection> extends (infer R extends object) ? R : Empty;
/** Resolve the combined context shape from an array of behaviors (intersection of all requirements). */
type ResolveBehaviorContext<Behaviors extends readonly AnyBehavior[]> = IntersectBehaviors<Behaviors, ContextProjection> extends (infer R extends object) ? R : Empty;
/** Resolve the combined config shape from an array of behaviors (intersection of all requirements). */
type ResolveBehaviorConfig<Behaviors extends readonly AnyBehavior[]> = IntersectBehaviors<Behaviors, ConfigProjection> extends (infer R extends object) ? R : Empty;
/**
* True if any property in `T` collapsed to `undefined` or `never` — indicating
* a type conflict from intersecting incompatible behavior requirements.
*
* - Required conflicts: `{ v: number } & { v: string }` → `{ v: never }` — caught via `[never] extends [undefined]`
* - Optional conflicts: `{ v?: number } & { v?: string }` → `{ v?: undefined }` — caught directly
*/
type HasConflict<T extends object> = true extends { [K in keyof T]: [T[K]] extends [undefined] ? true : never; }[keyof T] ? true : false;
/**
* Validate that a behavior composition has no type conflicts.
* Returns the behaviors tuple if valid, or an error message type if conflicts are detected.
*
* State, context, and config are all checked the same way — by intersecting
* each behavior's requirement and looking for collapsed fields. The
* intersection-based check applies the same rule to context as to state, so
* two behaviors that disagree on a context field's type (e.g. `Surface` vs
* `VideoSurface`) surface a conflict at compose time. The prior subtype-based
* approach for owners is gone — the unified rule is simpler and catches the
* cases where two behaviors silently agreed on a wider supertype.
*/
type ValidateComposition<Behaviors extends readonly AnyBehavior[]> = HasConflict<ResolveBehaviorState<Behaviors>> extends true ? 'Error: behaviors have conflicting state types' : HasConflict<ResolveBehaviorContext<Behaviors>> extends true ? 'Error: behaviors have conflicting context types' : HasConflict<ResolveBehaviorConfig<Behaviors>> extends true ? 'Error: behaviors have conflicting config types' : [...Behaviors];
/**
* A composition of behaviors with shared state and context signal maps.
*/
interface Composition<S extends object, C extends object> {
state: StateSignals<S>;
context: ContextSignals<C>;
destroy(): Promise<void>;
}
/**
* Options for `createComposition`.
*
* Composition derives the state and context signal maps from each
* behavior's declared `stateKeys` / `contextKeys`; `initialState` and
* `initialContext` seed those signals at creation time. Any unseeded
* signal starts as `undefined`.
*/
interface CompositionOptions<S extends object, C extends object, Cfg extends object> {
/** Static configuration passed to every behavior. */
config?: Cfg;
/** Initial values for state signals — any subset of `keyof S`. */
initialState?: Partial<S>;
/** Initial values for context signals — any subset of `keyof C`. */
initialContext?: Partial<C>;
}
declare function createComposition<const Behaviors extends readonly AnyBehavior[]>(behaviors: ValidateComposition<Behaviors>, options?: CompositionOptions<ResolveBehaviorState<Behaviors>, ResolveBehaviorContext<Behaviors>, ResolveBehaviorConfig<Behaviors>>): Composition<ResolveBehaviorState<Behaviors>, ResolveBehaviorContext<Behaviors>>;
/**
* Compose-time exhaustiveness check.
*
* Adds a phantom error tag to the parameter shape when `Keys` does not
* cover every key in `Slot`. The user's value won't satisfy the phantom
* field requirement, so TS surfaces the failure at the call site with a
* descriptive message. When exhaustive, the tag is `Empty` and adds no
* constraint.
*/
type ExhaustiveKeys<Keys extends readonly PropertyKey[], Slot extends object, Name extends string> = [keyof Slot] extends [Keys[number]] ? Empty : { [K in `Error: ${Name}Keys must list every key in the typed slice`]: Exclude<keyof Slot, Keys[number]>; };
/**
* Typed factory for behaviors that enforces single-behavior key/param
* consistency: declared `stateKeys` must equal `keyof S` (where `S` is
* inferred from the setup's `state` parameter type), and same for
* `contextKeys` / `C`.
*
* The `const` modifier on `SK` / `CK` captures literal tuples so e.g.
* `stateKeys: ['preload']` infers as `readonly ['preload']`, no `as
* const` needed at the call site.
*
* Cross-behavior consistency at `createComposition` is unchanged — the
* existing `IntersectBehaviors` machinery still runs over each
* behavior's setup param type.
*
* @example
* ```ts
* export const syncPreload = defineBehavior({
* stateKeys: ['preload'],
* contextKeys: ['mediaElement'],
* setup: ({ state, context }: {
* state: StateSignals<{ preload?: 'auto' | 'metadata' | 'none' }>;
* context: ContextSignals<{ mediaElement?: HTMLMediaElement | undefined }>;
* }) => { ... },
* });
* ```
*/
/**
* Deps shape for a behavior whose deps slot is empty (no keys). When a
* slot is empty, the corresponding deps field is optional — callers
* (typically tests) can omit it, and it defaults to `{}` at runtime via
* `createComposition`.
*
* When a slot has at least one key, the behavior reads `state.foo` /
* `context.bar` / `config.baz` and we need the field to be required so
* the access is type-safe.
*/
type RequireIfNonEmpty<Key extends string, T extends object> = keyof T extends never ? { [K in Key]?: T; } : { [K in Key]: T; };
type DepsForCfg<StateMap extends AnySlotMap, ContextMap extends AnySlotMap, Cfg extends object> = RequireIfNonEmpty<'state', StateMap> & RequireIfNonEmpty<'context', ContextMap> & RequireIfNonEmpty<'config', Cfg>;
declare function defineBehavior<StateMap extends AnySlotMap = Empty, ContextMap extends AnySlotMap = Empty, Cfg extends object = Empty, const SK extends readonly (keyof StateMap)[] = readonly [], const CK extends readonly (keyof ContextMap)[] = readonly [], R extends BehaviorCleanup = BehaviorCleanup>(behavior: {
stateKeys: SK;
contextKeys: CK;
setup: (deps: {
state: StateMap;
context: ContextMap;
config: Cfg;
}) => R;
} & ExhaustiveKeys<SK, StateMap, 'state'> & ExhaustiveKeys<CK, ContextMap, 'context'>): {
stateKeys: SK;
contextKeys: CK;
setup: (deps: DepsForCfg<StateMap, ContextMap, Cfg>) => R;
};
//#endregion
export { AnySlotMap, Behavior, BehaviorCleanup, BehaviorDeps, Composition, CompositionOptions, ContextSignals, InferBehaviorConfig, InferBehaviorContext, InferBehaviorState, ResolveBehaviorConfig, ResolveBehaviorContext, ResolveBehaviorState, StateSignals, createComposition, defineBehavior };
//# sourceMappingURL=create-composition.d.ts.map