Effect Schema Validation Pattern for Serialization in magnitudedev/magnitude

Magnitude uses the Effect Schema library to enforce runtime type safety and reliable JSON serialization through a six-step validation pattern that combines structural schemas, JSON-aware wrappers, and Effect-wrapped I/O operations.

The magnitudedev/magnitude repository implements a rigorous Effect Schema validation pattern to handle all data serialization concerns across its TypeScript codebase. This approach guarantees that every piece of serialized data—from browser local storage to RPC network payloads—undergoes strict validation at runtime while preserving compile-time type safety through schema-derived TypeScript types.

The Six-Step Effect Schema Validation Pattern

Magnitude applies this pattern uniformly across the codebase to ensure consistent data integrity. Each step serves a distinct purpose in the serialization lifecycle.

Define Structural Schemas with Schema.Struct

The foundation of the pattern begins by defining the data shape using Schema.Struct, Schema.Literal, Schema.Boolean, or other Effect Schema constructors. In web/src/stores/conversation-preferences.ts, the schema is defined as follows:

import { Schema } from "effect";

export const ConversationPreferencesSchema = Schema.Struct({
  showThinking: Schema.Boolean,
});

export type ConversationPreferences = typeof ConversationPreferencesSchema.Type;

The .Type property generates a TypeScript type that stays synchronized with the runtime schema definition, providing compile-time guarantees.

Create JSON-Aware Schemas with Schema.parseJson

To enable serialization, the structural schema is wrapped with Schema.parseJson, creating a schema that can transform between typed values and JSON strings. This step bridges the gap between TypeScript objects and their string representations:

const StoredConversationPreferencesSchema = Schema.parseJson(ConversationPreferencesSchema);

This wrapper ensures that encoding produces valid JSON and decoding validates the JSON structure against the defined schema.

Decode Unknown Inputs with Schema.decodeUnknownEither

When reading serialized data from external sources, Magnitude uses Schema.decodeUnknownEither to parse and validate the input. This function returns an Either type that explicitly handles success and failure cases without throwing exceptions:

import { Either } from "effect";

export function decodeConversationPreferences(value: string | null): ConversationPreferences {
  if (value === null) return defaultConversationPreferences;
  const decoded = Schema.decodeUnknownEither(StoredConversationPreferencesSchema)(value);
  return Either.isRight(decoded) ? decoded.right : defaultConversationPreferences;
}

This approach treats malformed data as a typed error case rather than a runtime exception, allowing graceful fallback to default values.

Serialize Data with Schema.encodeSync

For writing data, Schema.encodeSync converts typed values back into JSON strings synchronously. This function guarantees that the output conforms to the schema's structure:

export function encodeConversationPreferences(value: ConversationPreferences): string {
  return Schema.encodeSync(StoredConversationPreferencesSchema)(value);
}

Using encodeSync ensures that any value passed to storage APIs has been validated against the schema before serialization.

Type Errors Using Schema.TaggedError

Magnitude expresses I/O errors as Schema.TaggedError (or Data.TaggedError) to integrate with Effect's error handling model. These tagged errors enable precise error catching using Effect.catchTag:

import { Data, Effect } from "effect";

class ConversationPreferenceStorageError extends Data.TaggedError(
  "ConversationPreferenceStorageError"
)<{ readonly operation: "read" | "write" }> {}

This pattern creates distinct error types that carry semantic information about the failure context, making error handling both exhaustive and type-safe.

Wrap I/O in Effect.tryPromise

All storage operations are wrapped in Effect.tryPromise to keep side effects observable and composable within Effect workflows. As implemented in web/src/stores/conversation-preferences.ts, the pattern looks like this:

function readPreferences(storage: Storage) {
  return Effect.tryPromise({
    try: () => storage.getItem(STORAGE_KEY),
    catch: () => new ConversationPreferenceStorageError({ operation: "read" }),
  }).pipe(Effect.map(decodeConversationPreferences));
}

The catch constructor transforms any JavaScript exceptions into typed TaggedError instances, maintaining the purity of the Effect ecosystem while interfacing with impure browser APIs.

Implementation Examples from the Magnitude Codebase

The following files demonstrate how the six-step pattern adapts to different serialization contexts throughout the repository.

Local Storage in conversation-preferences.ts

The file web/src/stores/conversation-preferences.ts showcases the complete pattern implementation for persisting user preferences. It combines schema definition, JSON wrapping, decode/encode helpers, and Effect-wrapped storage operations into a cohesive module that handles showThinking boolean preferences.

Enum Validation in appearance-store.ts

In web/src/stores/appearance-store.ts, Magnitude uses Schema.Literal to validate enum-like values (such as theme preferences) through the same decode/encode flow. This demonstrates how the pattern handles simple discriminated unions alongside complex structs.

Branded IDs in workspace-tabs.ts

The web/src/lib/workspace-tabs.ts file extends the pattern with Schema.brand to create nominal typing for workspace identifiers. This prevents accidental mixing of different string types (like tab IDs vs. workspace IDs) while maintaining the same JSON serialization guarantees.

RPC Transport in subscription-wire.test.ts

Across package boundaries, packages/acn-protocol/src/transport/subscription-wire.test.ts applies Schema.encodeSync to RPC payloads. This illustrates how the same Effect Schema validation pattern ensures type safety in network communication, reusing schemas defined elsewhere in the monorepo.

Summary

  • Compile-time type safety is achieved through the .Type property on schema definitions, which generates TypeScript types synchronized with runtime validators.
  • Runtime validation catches malformed JSON before it enters application logic, preventing partial data corruption.
  • Consistent error handling uses TaggedError subclasses that integrate cleanly with Effect's catchTag and catchTags combinators.
  • Reusability allows identical schemas to serve local storage, RPC payloads, and configuration validation without duplication.
  • Side-effect management isolates impure I/O operations within Effect.tryPromise wrappers, maintaining referential transparency throughout the codebase.

Frequently Asked Questions

What is the Effect Schema validation pattern used for in Magnitude?

The pattern provides a type-safe pipeline for converting between TypeScript objects and serialized JSON strings while validating data integrity at every boundary. It ensures that data read from browser storage or received via network requests conforms to expected shapes before reaching business logic.

How does Schema.parseJson enable safe serialization?

Schema.parseJson wraps a structural schema to handle the JSON string layer, ensuring that encoded values are valid JSON strings and decoded values are parsed and validated against the underlying schema. This prevents common serialization errors like storing [object Object] or parsing malformed JSON without validation.

What is the difference between Schema.decodeUnknownEither and Schema.encodeSync?

Schema.decodeUnknownEither parses an unknown input (like a JSON string) and validates it against the schema, returning an Either<ParseError, A> to handle failures explicitly. Schema.encodeSync performs the inverse operation, converting a validated TypeScript value back into a JSON string synchronously, assuming the input already satisfies the schema type.

How does Magnitude handle validation errors in Effect workflows?

Magnitude wraps potential failures in Data.TaggedError classes and uses Effect.tryPromise to convert imperative exceptions into functional error channels. These tagged errors are then caught and handled using Effect.catchTag, allowing specific recovery logic for storage read failures versus write failures while maintaining full type safety throughout the error handling chain.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →