Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Core concepts
  2. The mental model

The mental model#

Three words describe everything @craft-ts does: declare, yield, derive.

You declare state with a name. Every named entity — a factory, a computed, a method — pulls in with yield* what it does not own, so the compiler can see that entity's dependencies. Everything else — validity, loading flags, form trees, error unions — is derived rather than restated.

This page is the why. If you want the how, the Learn path walks the same ideas through a working app.

Declare#

State is declared where it is used, close to the component or service that owns it, with a name that the tooling can see:

typescript
const counter = yield* state('counter', 0, ({ update }) => ({
  increment: () => update((value) => value + 1),
}));

The name isn't a label — it tags the injector (state:counter) and is how the primitive shows up in logs, snapshots and observability. Five primitives cover every home a value can have: memory, server (read and write), URL, and async action. They share one shape, so learning one teaches the other four — see Anatomy of a primitive.

Yield#

Classic injection hides the dependency graph. inject(TaskApi) is invisible from the outside, so the compiler cannot tell you when a provider is missing, and a test cannot tell you what to mock.

Yielding makes the same call visible in the type of the entity that yielded it:

typescript
const api = yield* TaskApi();

A factory that yields TaskApi has TaskApi in its graph. A computed that reads another primitive must yield that primitive too — otherwise the dependency is invisible on the computed, even if the surrounding factory already yielded it:

typescript
const doneCount = craftUse(craftComputed('doneCount', function* () {
  return (yield* tasks()).filter((task) => task.done).length;
}));

tasks does not belong to doneCount. Closing over tasks() would read the value and skip the graph. Everything downstream — the route DI check, the testing register, the dependency snapshot — reads the type of that entity.

And because you can yield part of a service — yield* TaskApi.fetchAll() — the graph records only what you actually used, which is what keeps test setups small.

That is the whole trade: one keyword, in exchange for a dependency graph the compiler can check. See Generators and yield*.

Derive#

The third principle is the one that removes the most code: if a value is a function of another value, don't store it — derive it.

  • Derived values are craftComputed, inside an insertion, and they yield* the readers they depend on.

  • Loading and error state is derived by the async primitives, not tracked by hand.

  • A form's field tree, validity and error types are derived from its state and its mutation (Forms).

  • Route exceptions are derived into a union the compiler forces you to handle exhaustively (Exceptions).

The payoff is that derived things cannot drift out of sync with their source. The cost is that you have to resist keeping a second copy "just for the template".

What follows from this#

Composition instead of configuration#

Behaviour is added by insertions — plain functions that receive a primitive's internals and return what to expose. Storage persistence, optimistic updates and forms are all the same shape as one you'd write yourself. Storage persistence uses the backend selected through DI:

typescript
const { myState } = state(
  'myState',
  0,
  insertStoragePersister(craftUnique({
    storeName: 'myStore',
    key: 'myState',
  })),
);

const { myQuery } = query(
  'myQuery',
  { params: () => 1, loader: /* … */ },
  insertStoragePersister(craftUnique({
    storeName: 'myStore',
    key: 'myUserQuery',
  })),
);

Compose several with the primitive-specific helpers in Typed insertion pipes. Keep craftPipe for universal or nested compositions.

Methods or events, your choice#

A method can be called directly, or bound to a source and driven by an event. Both coexist in the same declaration:

typescript
const resetSource$ = source$<void>('resetSource$');

const counter = yield* state('counter', 0, ({ set, update }) => ({
  increment: () => update((v) => v + 1), // called
  reset: on$(resetSource$, () => set(0)), // driven by an event, not exposed
}));

This is what makes one resetSource$.emit() reset several independent states at once, without any of them knowing about the others:

typescript
const search = yield* state('search', '', ({ set }) => ({
  set,
  reset: on$(resetSource$, () => set('')),
}));

const page = yield* state('page', 1, ({ set, update }) => ({
  increment: () => update((v) => v + 1),
  reset: on$(resetSource$, () => set(1)),
}));

See on$.

Granular state, granular tests#

Small, focused states isolate change. Combined with partial yields, a consumer depends on exactly what it reads — and a test provides exactly that, no more.

Services as functions#

A service is a factory with a name and a scope, not a class: craftService for the ones you write, and small host adapters for dependencies owned by the runtime. Both participate in the same typed composition and the same testing workflow.

typescript
const { UserProfile } = craftService(
  { name: 'UserProfile', providedIn: 'global' },
  function* () {
    const api = yield* UserApi();
    const userId = yield* state('userId', '5', ({ set }) => ({ set }));

    const updateEmail = yield* mutation('updateEmail', {
      method: (payload: { id: string; email: string }) => payload,
      loader: function* ({ params }) {
        return yield* api.updateEmail(params);
      },
    });

    yield* query(
      'user',
      {
        params: userId,
        loader: function* ({ params }) {
          return yield* api.getUser(params);
        },
      },
      insertReactOnMutation(updateEmail, {
        optimisticPatch: { email: ({ mutationParams }) => mutationParams.email },
        reload: { onMutationException: true },
      }),
    );

  },
);

Signals, not RxJS#

100% signal-based. craft-ts has no RxJS dependency: anything subscribable — an RxJS Observable included — works structurally, and when you do want streams (operators, multicast, typed exceptions), @craft-ts/stream provides them with the same typed dependencies and exceptions as the rest of craft-ts. See Typed streams.

Declarative code is legible code#

The three principles add up to something that is rarely stated outright: the app becomes declared data rather than control flow to be reconstructed. Two consequences follow, and both are worth more than they look.

Observability stops being instrumentation. Because every dependency resolution and every crafted function passes through one system, that system is where you wrap them — structured logs through a yieldable Console, correlation ids across the graph, per-service timing, snapshots of the live dependency tree — with no change to business code. Retrofitting the same thing onto imperative code means touching every call site. See Observability.

And what a tool can read, a tool can help with. The dependency graph, the reachable exceptions and the route contract are all declared, so an agent — or a future WebMCP-style integration — can reason about the app without inferring it from execution. The same property that makes the compiler able to check your providers makes the codebase tractable to something that isn't you.

Exceptions are values, errors are surprises#

A craft exception is a failure you declared and expect to handle; an error is the unexpected kind. Keeping them apart is what allows the compiler to check that you handled every declared case.

This is error-as-value: a declared failure is returned, not thrown, so it propagates through types rather than escaping through the stack. A try/catch tells you nothing about what it might catch; a returned craftException carries its code and payload all the way to whoever handles it — and the compiler knows if nobody does. See Exceptions as values.

See Also#

  • Which primitive should I use?

  • Anatomy of a primitive

  • Learn: the guided path

←Create a projectWhich primitive should I use?→

On this page

  • Declare
  • Yield
  • Derive
  • What follows from this
  • Composition instead of configuration
  • Methods or events, your choice
  • Granular state, granular tests
  • Services as functions
  • Signals, not RxJS
  • Declarative code is legible code
  • Exceptions are values, errors are surprises
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search