Effect v4 — Cheat Sheet

Les réflexes de prod : Schema · Services · Config · Retry · Cache · Streams · HTTP · Tests

🧭 La boussole — « Je veux… → j'utilise… »

Le guide de sélection rapide. En cas de doute, commence ici.

Je veux…J'utilise…
Un record / objet ordinaireSchema.Struct(...) + interface du même nom
Un ID scalaire / value objectSchéma brandé contraint
Un état interne de workflowData.TaggedEnum + $match exhaustif
Une union taguée qui traverse une frontièreSchema.TaggedUnion(...) (.cases, .guards, .match)
Une erreur typée attendueSchema.TaggedErrorClass
Décoder un payload inconnuSchema.decodeUnknownEffect(...)
Un service applicatifContext.Service + Layer.effect + Service.of(...)
Une méthode de service publiqueEffect.fn("Domaine.operation")
Lire la config runtimeConfig dans les layers — jamais process.env
Réessayer une opération transitoireEffect.retry(...) avec un Schedule borné
Un worker de pollingrunPass().pipe(Effect.repeat(Schedule.spaced(...)))
Un cache par clé avec TTL + dédupCache.make(...) / Cache.makeWith(...)
Mémoïser un seul résultatEffect.cached(...) / Effect.cachedWithTTL(...)
Une source d'événementsStream consommé via Stream.runForEach + Effect.forkScoped
Batcher N clés en 1 appel (vrai endpoint batch)Effect.request(...) + RequestResolver
Un appel HTTP sortantHttpClient Effect + décodage Schema
Tester du temps (sleep, retry, timeout)TestClock, jamais de vrai sleep
Synchroniser des fibers en testDeferred, Queue, Latch, Ref

🧬 Schema & modèles de données SCHEMA.md

DTO, contrats wire, brands, variants, décodeurs.

📦 Le record par défaut

export const User = Schema.Struct({
  id: UserId,
  name: Schema.NonEmptyString,
  email: Schema.optionalKey(Schema.String),
})
export interface User
  extends Schema.Schema.Type<typeof User> {}
  • .annotate({ identifier }) seulement si un outil le consomme (OpenAPI, RPC…)
  • Réutilise les champs : User.fields.name, Schema.fieldsAssign(...)

🔓 Construire vs décoder

schema.make(...)construction de confiance (throw)
schema.makeEffect(...)échec dans le canal d'erreur Effect
decodeUnknownEffectfrontières non fiables — le défaut
decodeUnknownSyncscripts, tests, startup uniquement
decodeUnknownResultcode pur sans Effect
💡 Jamais de cast pour esquiver la validation.

❓ Optionalité

  • Schema.optionalKey(...) → clé JSON absente (le cas courant)
  • Schema.optional(...) → seulement si undefined explicite fait partie du contrat
  • NullOr / NullishOr → seulement si le null est vraiment encodé
  • Valeurs par défaut = champs requis, défaut appliqué au décodage

🎭 Variants : interne vs frontière

// Interne : contrôle de flux
type Step = Data.TaggedEnum<{
  Continue: { readonly cursor: number }
  Finished: { readonly count: number }
}>
const Step = Data.taggedEnum<Step>()
Step.$match(next, { Continue: ..., Finished: ... })

// Frontière : décodage / persistance / wire
const Event = Schema.TaggedUnion({
  Started:  { runId: RunId },
  Finished: { runId: RunId, result: Schema.Json },
})
  • Discriminant externe (type, kind) : Schema.tag(...) + Schema.toTaggedUnion("type")

💥 Erreurs typées

export class PersistenceError extends
  Schema.TaggedErrorClass<PersistenceError>()(
    "UserRepo.PersistenceError",
    { operation: Schema.String, cause: Schema.Defect() },
  ) {}
  • Mappe les échecs d'infra en erreurs domaine aux frontières de service
  • Ajoute un label operation pour le diagnostic
  • Schema.Defect() pour les payloads de type defect

🏷️ Valeurs nominales

  • IDs et value objects → schémas brandés contraints
  • Contraintes de schéma normales avant Schema.brand(...)
  • Schema.fromBrand(...) seulement si le projet utilise déjà des constructeurs Brand

🧩 Services, Layers & modules SERVICES_LAYERS.md

Tags, surfaces de module, câblage runtime, Effect.fn.

🏗️ Le pattern module canonique

// user-repo.ts
export interface Interface {
  readonly get: (id: UserId) => Effect.Effect<User, NotFound | PersistenceError>
}

export class Service extends Context.Service<Service, Interface>()("@app/UserRepo") {}

export const layer = Layer.effect(Service, Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient
  const get = Effect.fn("UserRepo.get")(function* (id: UserId) { /* ... */ })
  return Service.of({ get })
}))

export class NotFound extends Schema.TaggedErrorClass<NotFound>()("UserRepo.NotFound", { id: UserId }) {}

export * as UserRepo from "./user-repo.js"  // auto-export du namespace
  • Le consommateur : const repo = yield* UserRepo.Service
  • N'exporte que la surface intentionnelle ; les helpers restent privés
  • Effect.fn("Domaine.op") pour toute méthode publique ou interne non triviale ; Effect.fnUntraced pour les helpers sans intérêt de trace

🧱 Choisir son constructeur de Layer

Layer.succeedservice déjà construit
Layer.syncconstruction synchrone paresseuse
Layer.effectacquisition effectful — le défaut
Layer.effectContext1 acquisition → plusieurs services (stubs de test)
Layer.unwrapla config choisit/construit le layer
Layer.freshacquisition isolée (rare, tests)

🔌 Câblage runtime

  • Layer.provide(...)cache la dépendance
  • Layer.provideMerge(...) → seulement si la dépendance doit rester exposée
  • Layer.mergeAll(...) → layers indépendants exposés
  • Préfère des valeurs de layers plates, triées topologiquement
⚠️ provideMerge / mergeAll ne sont pas des outils « fais-le compiler ».

♾️ Travail longue durée

export const layer = Layer.effectDiscard(
  Effect.gen(function* () {
    const events = yield* Events.Service
    yield* events.stream.pipe(
      Stream.runForEach(handleEvent),
      Effect.forkScoped,  // ← fork dans le scope du layer
    )
  }),
)
  • L'acquisition du layer doit se terminer — jamais de boucle infinie inline
  • Effect.forkScoped, FiberSet ou FiberMap
  • Pas de méthode publique start sauf besoin domaine explicite

🎁 Effect.fn avec transforms

const readAttachment = Effect.fn("Attachment.read")(
  function* (ref: AttachmentRef) {
    return yield* api.read(ref)
  },
  // wrapper qui voit les arguments d'origine :
  (effect, ref) => effect.pipe(
    attachmentError("Attachment.read", { attachmentId: ref.id }),
  ),
)
  • Bons transforms : classification d'erreur, logs, spans, retry, timeout, cleanup
  • 1 ou 2 transforms max — le générateur reste focalisé sur le workflow

⚙️ Config CONFIG.md

Jamais de process.env dans la logique applicative.

📖 Les recettes

const apiKey  = yield* Config.redacted("API_KEY")
const model   = yield* Config.option(Config.string("MODEL"))
const enabled = yield* Config.boolean("FEATURE_ENABLED")
  .pipe(Config.withDefault(false))
const dataDir = yield* Config.schema(AbsolutePath, "APP_DATA_DIR")
  • Config.redacted → credentials, toujours
  • Config.option → absence sémantique
  • Config.withDefault → donnée manquante seulement ; une valeur malformée échoue quand même

🌍 Providers

  • Défaut : ConfigProvider.fromEnv()
  • Tests : ConfigProvider.fromUnknown(...) (déterministe)
  • Remplacer : ConfigProvider.layer(provider)
  • Fallback : ConfigProvider.layerAdd(provider) (+ { asPrimary: true } pour surcharger)
  • constantCase → camelCase lit SCREAMING_SNAKE
  • nested(...) → préfixe de scope
💡 .env et env vars = sources de démarrage/frontière, pas des lectures en plein workflow métier.

🎛️ Le duo layer / layerConfig

export const layerConfig = (config: Config.Wrap<ClientOptions>) =>
  Layer.effect(Client.Service,
    Config.unwrap(config).pipe(
      Effect.flatMap(makeClient),
      Effect.map((client) => Client.Service.of(client)),
    ))
  • Expose layer(options) concret + layerConfig(Config.Wrap<Options>) pour le runtime
  • Test simple : Layer.succeed(AppConfiguration.Service, testConfig) si le décodage Config n'est pas ce qu'on teste

⏰ Scheduling & Retry SCHEDULING.md

Fini les while (true) + sleep. Schedule partout.

🧠 Les règles à retenir

  • Effect.retry → rejoue les échecs typés (pas les defects/interruptions)
  • Effect.repeat → répète les succès ; un échec stoppe
  • L'effet source tourne 1 fois avant le schedule
  • Schedule.recurs(3) = 3 reprises après le run initial
  • spaced = pause après le travail · fixed = cadence alignée
  • Backoff : exponential / fibonacci + jittered (anti-tempête) + upTo({ times })
  • Fallback en fin de retries : Effect.retryOrElse(...)
🎯 Retry uniquement à la frontière la plus étroite, avec idempotence prouvée.

🔁 Worker de polling

const pass = runPass().pipe(
  Effect.tapError((e) => Effect.logError("Worker.pass_failed", e)),
  Effect.ignore,  // échecs attendus : loggés, on continue
)
const run = pass.pipe(
  Effect.repeat(Schedule.spaced("1 second")),
)
  • Échecs typés gérés dans la passe, avant le repeat
  • Les defects continuent de defecter → supervision
  • Récupération de cause seulement aux frontières de supervision

📐 Politique de retry réutilisable

const retrySchedule = Schedule.exponential("100 millis").pipe(
  Schedule.jittered,
  Schedule.upTo({ times: 5 }),
)

reconcile(target).pipe(
  Effect.retryOrElse(
    retrySchedule.pipe(Schedule.tapInput((e) => logRetry(e))),
    (e) => Effect.logError("reconcile.stopped", e),
  ),
)
  • Rate-limit provider : Schedule.passthrough + Schedule.modifyDelay → max(backoff, retryAfterMs)

⏳ Timeouts & délais

  • Effect.timeout(...) → vraie deadline
  • Effect.delay(...) → démarrer plus tard
  • Effect.sleep(...) en prod → seulement si dormir est le comportement métier
  • Batch : échecs par item isolés avec tapError + ignore autour de chaque item (Effect.forEach(..., { concurrency }))
  • En test : TestClock, jamais de vrai temps

💾 Cache & mémoïsation CACHING.md

Stop aux Map + timestamp + boucle de prune maison.

🗝️ effect/Cache

  • Cache.make({ capacity, lookup, timeToLive }) → TTL fixe pour tout
  • Cache.makeWith(lookup, { capacity, timeToLive(exit, key) }) → TTL par entrée selon l'Exit
  • Dédup intégrée : les get concurrents d'une clé manquante partagent 1 lookup
  • capacity obligatoire → borne le cache, plus de prune manuel
  • invalidate / refresh pour la staleness explicite
  • Ressources à cleanup (connexions) → ScopedCache

🎯 TTL selon l'Exit

const cache = yield* Cache.makeWith(
  (ref: string) => resolveUncached(ref),
  {
    capacity: 300,
    timeToLive: (exit) =>
      Exit.isSuccess(exit) && exit.value.cacheable
        ? "10 minutes"
        : Duration.zero,  // ← ne cache pas le dégradé
  },
)
  • TTL zéro → échecs transitoires non cachés, sans faire échouer l'appelant
  • Negative-cache court possible pour les échecs stables (not-found)

🧭 Quel outil ?

Même clé répétée dans le tempsCache
Même clé en rafale concurrenteCache (lookup partagé)
N clés distinctes + endpoint batch réelEffect.request + RequestResolver
N clés distinctes, endpoint unitaireEffect.forEach(..., { concurrency }) ± Cache
Une seule valeur, pas de cléEffect.cached / cachedWithTTL

⚠️ Pièges classiques

  • Cache construit par appel → il ne cache rien. Construis-le 1 fois dans le layer
  • Acquisition de client scopée dans le lookup → acquiers dans le layer (Layer.build), le lookup devient un simple appel
  • RequestResolver sur une API REST unitaire → n'apporte rien, c'est une boucle déguisée

🌊 Streams STREAMS.md

Stream<A, E, R> : source pull-based, backpressurée — la consommation contrôle la demande.

🚰 Choisir sa source

Valeurs en mémoireStream.make / fromIterable
Frontière callback → queueQueue + Stream.fromQueue
Broadcast (tous les abonnés)PubSub + Stream.fromPubSub
Valeur courante + changementsSubscriptionRef
API paginéeStream.paginate (step déjà effectful)
Ticks planifiés avec valeursStream.fromSchedule
AsyncIterable plateformeStream.fromAsyncIterable
Stream après lecture de servicesStream.unwrap
🚫 Pas de stream juste pour boucler : effet répété sans valeurs = Effect.repeat + Schedule.

🔧 Transformer & consommer

Pur / effectfulmap / mapEffect
Concurrence bornéemapEffect(fn, { concurrency }) (+ unordered: true si l'ordre est indifférent)
1 → 0..n sortiesflatMap
Avec étatmapAccum / mapAccumEffect
Consommateur à effetsrunForEach
Faire tourner sans les valeursrunDrain
Tests / flux finistake(n) + runCollect
⚠️ Jamais de runCollect sur un flux de prod non borné.

🏠 Consommateur longue durée

export const layer = Layer.effectDiscard(
  Effect.gen(function* () {
    const gateway = yield* Gateway.Service
    yield* gateway.events.pipe(
      Stream.filter(isMessageEvent),
      Stream.runForEach(handleEvent),
      Effect.forkScoped,
    )
  }),
)
  • Le layer possède la durée de vie du stream
  • Interface de service : expose des Stream, garde Queue/Ref privés
  • Travail par clé (session, channel) → helper nommé sur FiberMap, pas des maps de fibers ad hoc

🛗 Backpressure & buffers

  • D'abord la backpressure naturelle ; Stream.buffer seulement pour découpler producteur/consommateur
  • "suspend" = backpressure · "dropping" = jette le nouveau · "sliding" = garde le plus récent
  • Stream.debounce → période calme · Stream.throttle → débit
  • Erreurs : Stream.mapError aux frontières, catchTag/catchIf typés, catchCause réservé à la supervision

🌐 HTTP Clients HTTP_CLIENTS.md

Modules effect/unstable/http/* : HttpClient, HttpClientRequest, HttpClientResponse.

📮 L'adaptateur possède toute la frontière

  • Construire la requête → auth/headers → exécuter → classifier le status → décoder le body → mapper en erreurs domaine typées → retry si idempotent
  • HttpClientRequest.prependUrl (base URL), bearerToken, acceptJson, schemaBodyJson
  • HttpClient.filterStatusOk avant de décoder
  • HttpClientResponse.schemaBodyJson (body) / schemaJson (status+headers+body)
  • Handlers HTTP entrants fins : décoder, appeler les services, mapper les erreurs — la logique métier vit dans les services

🔄 Retry & rate limit

  • HttpClient.retryTransient(...) → transport, timeout, 408, 429, 500, 502, 503, 504
  • HttpClient.withRateLimiter(...) → pacing proactif, apprend des headers Retry-After (retry 429 par défaut)
  • Retry métier (payloads provider, idempotence) → Effect.retry au niveau opération (cf. ⏰)
  • Appels provider/réseau hors des transactions DB

🩹 L'exception raw fetch (si vraiment nécessaire)

const request = Effect.fn("Provider.request")(function* (input: RequestInput) {
  const response = yield* Effect.tryPromise({
    try: (signal) => fetch(input.url, { signal }),  // ← branche l'AbortSignal !
    catch: (cause) => new ProviderError({ operation: "Provider.request", cause }),
  })
  if (!response.ok) return yield* Effect.fail(new ProviderRejected({ status: response.status }))
  const json = yield* Effect.tryPromise({ try: () => response.json(), catch: toProviderError })
  return yield* Schema.decodeUnknownEffect(ResponseSchema)(json)
})
  • Légitime pour : transports plateforme, contraintes browser/edge, libs qui évitent les APIs unstable
  • Toujours dans un service adaptateur, avec la même discipline de frontière

🧪 Tests TESTING.md

Déterministe > sleep. Toujours.

🏁 Les défauts

it.effect("finds a user", () =>
  Effect.gen(function* () {
    const users = yield* UserRepo.Service
    const result = yield* users.find(UserId.make("u1"))
    expect(Option.isSome(result)).toBe(true)
  }).pipe(Effect.provide(UserRepo.testLayer)),
)
  • it.effect par défaut ; it.live seulement si le temps réel est le sujet du test
  • TestClock.setTime / adjust pour sleeps, schedules, retries, timeouts
  • Fork les effets dormants avant d'avancer la TestClock

🤝 Synchronisation, pas de sleep

Deferredsignal one-shot (prêt / terminé)
Queuepasser du travail / des événements observés entre fibers
Latchporte ouvrir/fermer réutilisable
Refétat d'observation partagé
yield* runWorker.pipe(Effect.forkScoped)
yield* Deferred.await(ready)     // attend le signal…
const msg = yield* Queue.take(published)  // …pas une durée

🎭 Stubs de test first-class (double tag)

export interface TestInterface extends Interface {
  readonly sentMessages: () => Effect.Effect<ReadonlyArray<Message>>
  readonly failNextSend: (error: SendError) => Effect.Effect<void>
}

export const testLayer = Layer.effectContext(Effect.gen(function* () {
  const service = TestService.of({ /* même objet pour les 2 tags */ })
  return Context.empty().pipe(
    Context.add(Service, service),      // la prod dépend de celui-ci
    Context.add(TestService, service),  // le test contrôle via celui-là
  )
}))
  • Le même objet derrière les deux tags ; la prod ne connaît que le vrai tag
  • Layer.succeed pour les fakes statiques ultra-simples ; Layer.mock pour les mocks partiels dont les membres omis doivent échouer bruyamment
  • Config en test : ConfigProvider.fromUnknown(...) si on teste le décodage, sinon Layer.succeed(AppConfiguration.Service, config)

☠️ Les interdits absolus

Si tu es en train de faire ça, arrête-toi.

  • as any, !, casts non vérifiés pour faire taire le typage Effect
  • Schema.Class / Schema.TaggedClass comme modèle de données par défaut
  • Classes d'erreur _tag à la main quand Schema.TaggedErrorClass convient
  • Récupération au niveau cause quand l'erreur typée suffit
  • Layer.mergeAll / provideMerge en mode « fais-le compiler »
  • Autorité, credentials, persistance ou transports cachés derrière des défauts Context.Reference
  • Effect.sleep(...) arbitraire en test quand une primitive déterministe existe
  • Caches Map/TTL/prune ou dédup in-flight maison quand effect/Cache convient
  • Retry sans idempotence prouvée
  • Avaler les échecs épuisés sans fallback véridique à la frontière