Skip to content

Instantly share code, notes, and snippets.

@kevinmichaelchen
Last active November 18, 2025 02:07
Show Gist options
  • Select an option

  • Save kevinmichaelchen/84c0ce72b5e33b39062822dcf6c7f595 to your computer and use it in GitHub Desktop.

Select an option

Save kevinmichaelchen/84c0ce72b5e33b39062822dcf6c7f595 to your computer and use it in GitHub Desktop.
What's New in Effect v4

What's New in Effect v4

Table of Contents


Schema Type System

v4 schemas track 14 type parameters instead of v3's 6-8, preserving complete type information through composition.

v3:

interface Schema<A, I = A, R = never>

v4:

interface Bottom<
  T, // Decoded type
  E, // Encoded type
  RD, // Decode services
  RE, // Encode services
  Ast, // AST node
  RebuildOut, // Type after modification
  TypeMakeIn, // Constructor input
  Iso, // Optic focus
  TypeParameters, // Generic params
  TypeMake, // Constructor type
  TypeMutability, // readonly | mutable
  TypeOptionality, // required | optional
  TypeConstructorDefault, // no-default | with-default
  EncodedMutability, // Encoded mutability
  EncodedOptionality // Encoded optionality
>

This eliminates type information loss when composing schemas. Mutability, optionality, and constructor defaults are now tracked at the type level.

Real impact: Schema derivation APIs (like mapFields, pick, omit) preserve exact type characteristics, enabling safer refactoring and more precise TypeScript autocomplete.


Separate Decode/Encode Services

v3 used a single R parameter for all service dependencies. v4 splits this into RD (decode requirements) and RE (encode requirements).

v3:

// Both encode and decode require Database service
declare const UserSchema: Schema<User, string, Database>

v4:

// Only decode requires Database; encode needs nothing
declare const UserSchema: Codec<User, string, Database, never>

// Decoding: Effect<User, SchemaError, Database>
const decode = Schema.decodeEffect(UserSchema)("user-123")

// Encoding: Effect<string, SchemaError, never>
const encode = Schema.encodeEffect(UserSchema)(user)

This enables better tree-shaking and eliminates false service dependencies.

Real impact: API responses no longer require database services just because request parsing does. Cleaner Layer dependency graphs and smaller production bundles.


Explicit JSON Serialization

v3 provided implicit JSON serialization. v4 requires explicit codec creation.

v3:

// Implicit JSON handling
const serialized = Schema.encode(schema)(value)

v4:

// Explicit JSON codec
const codec = Schema.makeSerializerJson(schema)
const serialized = codec.serialize(value)

// Helper for JSON strings
const schema = Schema.fromJsonString(Schema.Number)

The explicit API reduces bundle size by requiring opt-in for JSON features.

Real impact: Serverless functions and Docker images are 50-100KB smaller. Faster cold starts for Lambda/Cloud Functions.


First-Class Transformations

v3 embedded transformations in schemas. v4 makes them standalone, composable objects.

v3:

const schema = Schema.transform(
  Schema.Number,
  Schema.Date,
  (n) => new Date(n),
  (d) => d.getTime()
)

v4:

const transform = Transformation.transform({
  decode: (epochMillis: number) => new Date(epochMillis),
  encode: (date: Date) => date.getTime()
})

const DateFromEpochMillis = Schema.Date.pipe(Schema.encodeTo(Schema.Number, transform))

// Transformations are inspectable and reusable
console.log(transform) // Shows decode/encode logic

Transformation objects can be tested independently and composed like optics.

Real impact: Share transformation logic across schemas (e.g., date parsing, ID lookups). Unit test transformations without schema overhead.


First-Class Filters

v3 baked validation into schema definitions. v4 separates filters as composable objects with multi-issue reporting.

v3:

const schema = Schema.Number.pipe(Schema.positive, Schema.int)

v4:

const positiveInt = Schema.Number.pipe(
  Schema.filter(Schema.positive({ errors: "all" })),
  Schema.filter(Schema.int({ errors: "all" }))
)

// Reports all validation failures at once
const result = Schema.decodeUnknownSync(positiveInt)(-3.14)
// Error: Expected positive number, expected integer

The errors: "all" option collects all validation failures instead of stopping at the first error.

Real impact: API clients fix all validation errors in one request instead of multiple round trips. Better UX with complete error context.


Constructor API

v3 had mixed validation behavior in constructors. v4 introduces explicit makeUnsafe() with clear error handling.

v3:

const value = schema.make(input) // May or may not validate

v4:

try {
  const value = schema.makeUnsafe(input)
} catch (e) {
  // Error is in e.cause as SchemaError
  console.error(e.cause)
}

// For Effect context
const program = Effect.gen(function* () {
  const value = yield* schema.make(input)
  return value
})

The makeUnsafe name makes validation explicit. For Effect-based code, use schema.make() which returns Effect<A, SchemaError>.

Real impact: No more wondering if a constructor validates. Clear separation between throwing (makeUnsafe) and Effect-based (make) constructors.


Integrated Optics

v3 treated optics as a separate concern. v4 derives optics directly from schemas.

v4:

import { Optic } from "effect"

const employee = {
  company: {
    address: {
      street: {
        name: "main street"
      }
    }
  }
}

// Auto-derived from schema structure
const updated = Optic.id<Employee>().key("company").key("address").key("street").key("name").modify(String.capitalize)(
  employee
)

// Result: { company: { address: { street: { name: "Main Street" } } } }

Type-safe lenses with full autocomplete work seamlessly with schema definitions.

Real impact: Update deeply nested API response structures without manual spread operators. Type-safe field updates with compiler-enforced correctness.


Enhanced STM Collections

v4 adds comprehensive transactional collections for lock-free concurrent state management.

v4 New Collections:

import { TxHashMap, TxQueue, Effect } from "effect"

// Transactional hash map
const program = Effect.gen(function* () {
  const map = yield* TxHashMap.make<string, number>()

  yield* Effect.atomic(
    Effect.gen(function* () {
      yield* TxHashMap.set(map, "a", 1)
      yield* TxHashMap.set(map, "b", 2)
      const sum =
        (yield* TxHashMap.get(map, "a")).pipe(Option.getOrElse(() => 0)) +
        (yield* TxHashMap.get(map, "b")).pipe(Option.getOrElse(() => 0))

      if (sum > 10) {
        return yield* Effect.interrupt // Rolls back entire transaction
      }

      return sum
    })
  )
})

All operations compose atomically with Effect.atomic(). Available collections: TxHashMap, TxHashSet, TxQueue, TxChunk, TxSemaphore.

Real impact: Build lock-free concurrent systems (rate limiters, caches, job queues) without explicit mutex management. Automatic rollback on errors.


Breaking Changes

Result/Either API

Removed zipWith and ap. Use all() for parallel composition:

// v3
const result = Result.zipWith(a, b, (x, y) => x + y)

// v4
const result = Result.all([a, b]).pipe(Result.map(([x, y]) => x + y))

getOrThrow Behavior

Now throws the error directly instead of wrapping it:

// v3
try {
  const value = Result.getOrThrow(result)
} catch (wrapped) {
  const error = wrapped.error // Wrapped
}

// v4
try {
  const value = Result.getOrThrow(result)
} catch (error) {
  // Error thrown directly
}

Migration Guide

Schema Changes

  1. Replace implicit JSON usage:
// v3
const encoded = Schema.encode(schema)(value)

// v4
const codec = Schema.makeSerializerJson(schema)
const encoded = codec.serialize(value)
  1. Update constructors:
// v3
const value = schema.make(input)

// v4 (throws on error)
const value = schema.makeUnsafe(input)

// v4 (Effect-based)
const value = yield * schema.make(input)
  1. Separate decode/encode services:
// v3
Schema<A, I, R>

// v4
Codec<A, I, RD, RE>

Result/Either Changes

Replace removed methods:

// v3
Result.zipWith(a, b, fn)
Result.ap(fab, fa)

// v4
Result.all([a, b]).pipe(Result.map(([x, y]) => fn(x, y)))
Effect.all([fa, fb]).pipe(Effect.map(([a, b]) => fn(a, b)))

Unchanged APIs

These modules have stable APIs across v3 and v4:

  • Array, BigDecimal, BigInt, Boolean, Brand, Chunk
  • Iterable, Number, Order, Ordering, Predicate, Record
  • Redacted, RegExp, String, Struct, Symbol, Tuple

Core Effect concepts remain unchanged:

  • Effect<A, E, R> monad
  • Fiber-based concurrency
  • Layer dependency injection
  • TestClock for deterministic testing
  • Stream/Channel architecture
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment