Skip to content

Algebraic Effects

Status: v1 entregue (runtime + sintaxe + verificação estrita de efeitos). Row polymorphism completo chega em v2 — ver §13. Spec longa: ADR 0003. Exemplos rodáveis: examples/features/25-effects/.

Algebraic effects são o mecanismo pelo qual uma função declara que precisa de capacidades (ler arquivo, logar, sortear, falar com banco) sem se acoplar à implementação delas. Quem chama decide o que cada capacidade significa instalando handlers — e a mesma função roda inalterada em produção, em testes, em modo --dry-run, em replay, em sandbox.

Se você já conhece dependency injection, traits dyn, mocks ou monads, efeitos cobrem todos esses casos com uma única abstração mais leve.


1. TL;DR #

use std::fs

effect IO {
  fn read(path: str) -> str
}

fn parse_config() with IO -> Config {
  let raw = perform IO::read("app.toml")
  return Config.parse(raw).unwrap()
}

// Em produção
let cfg = handle parse_config() with {
  IO::read(p) => fs.read_to_string(p),
}

// Em teste — mesma fn, handler diferente
let cfg = handle parse_config() with {
  IO::read(_) => "[server]\nport = 8080",
}

A assinatura with IO é parte do tipo. A função não conhece fs, não recebe um logger via parâmetro, não tem global. O significado de IO::read é decidido na borda, no handle.


2. Por que isso importa #

A pergunta que efeitos respondem:

"Como faço minha função parse_config() ler arquivo, logar, e medir tempo — sem acoplar ela a fs, Logger e Clock?"

Resposta tradicional, três caminhos, todos ruins:

Estratégia Problema
Passar tudo como parâmetro (fn parse(fs, log, clock)) Contamina toda a árvore de chamadas. Adicionar uma dependência muda 50 assinaturas.
Singletons globais (std::fs::read) Fácil de chamar, impossível de testar sem mocks invasivos. Vaza entre threads/requests.
Trait objects / DI containers (fn parse(deps: &dyn Deps)) Boilerplate enorme. Cada nova dep mexe na interface. dyn allocations no caminho quente.

Efeitos são o quarto caminho:

  • Assinatura declara o quê, não comowith IO + Logger + Clock substitui três parâmetros.
  • Handler é local e lexical — sem singleton global, sem container.
  • Compõe com + — adicionar uma dep não muda a forma da função.
  • Testável por construção — handler de teste é um literal de mapa.

3. A ideia em 3 minutos #

Um efeito tem quatro peças:

Peça Sintaxe Função
Declaração effect IO { fn read(...) -> str } Nomeia o efeito + suas operações
Anotação fn parse() with IO -> Config { ... } Declara que a função usa IO
Invocação perform IO::read("foo") Dispara a operação no site do uso
Interpretação handle expr with { IO::read(p) => ... } Instala handlers para a sub-árvore

A semântica:

  • A declaração effect IO { ... } só fixa a forma das operações (assinaturas).
  • Quem invoca (perform) só sabe que precisa do efeito — fica desacoplado da implementação.
  • Quem define o que IO faz é o handle, não a declaração.
  • Sem handler instalado, perform lança um erro de runtime unhandled effect — ou seja, omitir o handler é o sandbox.

4. Sintaxe completa #

4.1 Declarando um efeito #

effect IO {
  fn read(path: str) -> str
  fn write(path: str, data: str)
}
  • effect é uma keyword.
  • Cada operação é uma assinatura fn op(params) [-> Ret] — sem corpo.
  • Operações sem -> Ret retornam nil.
  • Nomes de operação devem ser únicos dentro do mesmo effect (parser rejeita duplicados).

4.2 Declarando que uma função usa um efeito #

fn parse_config(path: str) with IO -> Config { ... }

fn save_log(msg: str) with IO + Logger { ... }

fn pure_helper(n: int) -> int { ... }    // sem efeitos
  • A cláusula with aparece entre os parênteses dos parâmetros e a flecha de retorno.
  • Múltiplos efeitos: with E1 + E2 + E3 (ordem irrelevante).
  • Em v1 o verificador estrito emite erro de compilação TE800 se você esquecer a anotação em código que usa perform, e TE802 se um chamador não cobrir os efeitos do chamado (ver §12 e §13).

4.3 Invocando uma operação #

let config = perform IO::read("config.toml")
perform IO::write("out.log", "hello")
  • Caminho qualificado obrigatório: Effect::op. Forma perform op(...) (sem efeito) não compila.
  • O valor que perform produz é o que a cláusula Effect::op no handle mais próximo devolveu.

4.4 Instalando handlers #

let result = handle parse_config("c.toml") with {
  IO::read(path) => fs.read_to_string(path),
  IO::write(_, _) => panic("read-only filesystem"),
}
  • A expressão à esquerda de with é o corpo que executa com handlers ativos.
  • Cada cláusula Effect::op(params) => body define como interpretar perform Effect::op(...).
  • O valor de retorno da cláusula é o que o perform correspondente vê.
  • Cláusulas são closures normais — têm acesso ao escopo léxico do handle.

5. Galeria de aplicações #

Onde efeitos mudam o jogo na prática.

5.1 Servidores HTTP / APIs REST #

O caso mais óbvio. Cada handler precisa: ler banco, logar, autenticar, enviar evento.

effect Db    { fn query(sql: str) -> [User] }
effect Log   { fn info(msg: str) }
effect Metric { fn count(name: str) }

fn create_user(input: NewUser) with Db + Log + Metric -> User {
  perform Log::info("creating user {input.email}")
  let users = perform Db::query("SELECT * FROM users WHERE email = '{input.email}'")
  if users.len() > 0 { panic("email taken") }
  perform Metric::count("user.created")
  return User.new(input)
}

Em produção:

handle create_user(input) with {
  Db::query(sql)  => sqlite.query(connection, sql),
  Log::info(msg)  => journald.write(msg),
  Metric::count(n) => prometheus.inc(n),
}

Em teste:

let logs = []
let metrics = #{}
let result = handle create_user(input) with {
  Db::query(_)    => [],
  Log::info(msg)  => logs.push(msg),
  Metric::count(n) => metrics[n] = (metrics[n] ?? 0) + 1,
}

assert(logs.contains("creating user [email protected]"))
assert(metrics["user.created"] == 1)

Mesmo create_user, dois mundos. Sem trait objects, sem mocking framework.

5.2 Pipelines ETL #

Você lê de S3, transforma, escreve em Postgres, manda métrica. Em dev quer ler local, escrever SQLite, métrica vira print.

use std::Array

effect Source { fn read(path: str) -> [Record] }
effect Sink   { fn write(table: str, rows: [Record]) }

fn pipeline() with Source + Sink {
  let raw = perform Source::read("s3://bucket/2026/sales.csv")
  let cleaned = raw |> Array.filter(|r| r.amount > 0)
  perform Sink::write("sales_clean", cleaned)
}

Um pipeline. Dois handle ... with. Zero código duplicado.

5.3 Game engines #

NPC quer atacar player → consulta posição, sortear dano, tocar som, log de combate.

fn attack(self, target) with World + Random + Audio + CombatLog -> Damage {
  let pos = perform World::position_of(target)
  let crit = perform Random::float() < self.crit_chance
  let dmg = self.base_dmg * (crit ? 2 : 1)
  perform Audio::play("hit.ogg")
  perform CombatLog::record(self, target, dmg)
  return Damage.new(dmg, crit)
}

Replay/save: handler de Random lê de um seed gravado → game determinístico. Headless tests: Audio é no-op, World é fake → testa AI sem rodar engine. Demos: World lê de um arquivo gravado → roda gameplay sem input do player.

5.4 CLI tools / DevOps #

deploy_service precisa: ler config, falar com Kubernetes, abrir PR, mandar Slack.

use std::fs

fn deploy(service: str) with Fs + K8s + Github + Slack {
  let cfg = perform Fs::read("services/{service}.toml")
  perform K8s::apply(cfg)
  perform Github::create_pr(service, "Deploy {service}")
  perform Slack::post("#deploys", "Deploying {service}")
}

// `--dry-run` é só trocar handlers
handle deploy(name) with {
  Fs::read(p)        => fs.read(p),                    // real
  K8s::apply(cfg)    => print("[dry] would apply: {cfg.name}"),
  Github::create_pr(_, _) => print("[dry] would open PR"),
  Slack::post(_, _)  => print("[dry] would notify"),
}

if dry_run { ... } else { ... } espalhado pelo código? Acabou. Trocar 4 cláusulas no entry-point é o --dry-run.

5.5 Compiladores e LSPs #

Mesmo compilador, três frontends:

  • LSP server instala handlers que vão pro VFS (memória do editor)
  • CLI instala handlers que leem do disco
  • Fuzzer instala handlers que retornam input aleatório
fn type_check(module: str) with FileSystem + Cache -> [Diagnostic] {
  let src = perform FileSystem::read(module)
  let cached = perform Cache::lookup(module, hash(src))
  if cached.is_some() { return cached.unwrap() }
  let result = analyze(src)
  perform Cache::store(module, hash(src), result)
  return result
}

Roslyn, rust-analyzer, gopls fazem isso à mão com traits. Efeitos automatizam.

5.6 Retry / circuit breaker #

use std::http

fn fetch_user(id: int) with Http -> User {
  let json = perform Http::get("https://api/users/{id}")
  return User.from_json(json).unwrap()
}

// Wrapper que aplica retry; o interno chama HTTP real
fn with_retry<T>(action: fn() -> T with Http) -> T with Http {
  let attempt = 0
  while true {
    let result = handle action() with {
      Http::get(url) => {
        let r = http.get(url)
        if r.is_err() && attempt < 3 {
          attempt = attempt + 1
          sleep(2 ^ attempt seconds)
          continue
        }
        return r.unwrap()
      },
    }
    return result
  }
}

// Use:
let user = with_retry(|| fetch_user(42))

fetch_user permanece ingênuo. Trocar de retry exponencial para circuit-breaker é trocar a fn de wrapping.

5.7 Property-based testing #

A função declara with Random. O framework instala handlers que registram a sequência de números sorteados. Quando um teste falha, faz shrink alterando essa sequência.

fn sort_preserves_length(input: [int]) with Random -> bool {
  let perm = perform Random::shuffle(input)
  return perm.len() == input.len()
}

// QuickCheck-like driver:
property_test(sort_preserves_length, generations: 1000)

O driver instala um handler de Random que produz inputs determinísticos a partir de um seed, registra a sequência, e regenera com seeds menores quando encontra falha. QuickCheck para código com I/O vira possível.

5.8 Sistemas embarcados / firmware #

Drivers viram efeitos:

effect Gpio { fn write(pin: int, value: bool) }
effect I2c  { fn read(addr: u8, reg: u8) -> u8 }

fn read_temperature() with I2c -> float {
  let raw = perform I2c::read(0x48, 0x00)
  return raw as float * 0.0625
}

No host: handlers simulam o periférico em RAM, você roda cargo test. No target: handlers chamam o hardware real. Mesmo binário lógico, dois backends de I/O. Hoje isso é feito com embedded-hal no Rust — cada periférico exige um trait novo. Efeitos são mais leves.

5.9 Plugin sandboxing #

Embute Zolo num app maior. O app define quais efeitos o script pode realizar. Quer um plugin sem acesso ao filesystem? Não passe o handler de FileSystem::write. Capabilities por construção, sem VM separada, sem sandbox de syscall.

// O host instala só os efeitos seguros:
handle plugin.run() with {
  Console::log(msg) => app.show_notification(msg),
  // Fs, Net, Process — não instalados → plugin não pode usar
}

Plugin tenta perform Fs::write(...) → erro de runtime unhandled effect. Sem possibilidade de escapar.

5.10 Web/UI orientada a estado #

Frameworks reativos precisam ler cookie, navegar router, persistir local storage, falar HTTP. SvelteKit/Remix loaders teriam vida mais fácil se "esse loader pode ler cookies" fosse parte do tipo da função, não convenção.

fn load_dashboard() with Cookies + Db + Cache -> DashboardData {
  let user_id = perform Cookies::read("session")?.user_id
  let cached = perform Cache::lookup("dash:{user_id}")
  if cached.is_some() { return cached.unwrap() }
  let data = perform Db::query("SELECT ... WHERE user_id = {user_id}")
  perform Cache::store("dash:{user_id}", data)
  return data
}

6. Padrões idiomáticos #

6.1 Handlers aninhados — escopo de override #

use std::fs

fn read_three() with IO -> str {
  return "{perform IO::read(\"a\")}|{perform IO::read(\"b\")}|{perform IO::read(\"c\")}"
}

// Handler externo: arquivo real
handle read_three() with {
  IO::read(p) => {
    // Para o segundo arquivo, usa um stub — sem rebuildar o resto
    if p == "b" {
      return handle perform_b() with { IO::read(_) => "STUB" }
    }
    return fs.read_to_string(p)
  },
}

Casos reais: log gravado durante uma seção, retry com timeout-por-tentativa, snapshot de uma chamada.

6.2 Capturando estado — handler-as-recorder #

let logs = []
let result = handle action() with {
  Log::info(msg) => logs.push(msg),
}
// `logs` agora tem o histórico

Closure captura. O handler grava. O teste assert no logs.

6.3 Decorando uma função sem mudá-la #

fn with_timing<T>(label: str, action: fn() -> T with Clock) -> T with Clock {
  let start = perform Clock::now()
  let result = action()
  let elapsed = perform Clock::now() - start
  print("{label} took {elapsed}ms")
  return result
}

// Use:
let user = with_timing("fetch_user", || fetch_user(42))

Adicionar timing é uma função-de-ordem-superior. Sem decorators, sem AOP.

6.4 Substituindo um efeito por outro #

fn translate<T>(action: fn() -> T with Http) -> T with Net {
  return handle action() with {
    Http::get(url) => perform Net::request(url, "GET"),
    Http::post(url, body) => perform Net::request(url, "POST", body),
  }
}

Adapter pattern: handler converte chamadas de um efeito para outro. Útil em portas.

6.5 Default handlers via wrapper #

fn with_default_logger<T>(action: fn() -> T with Log) -> T {
  return handle action() with {
    Log::info(msg)  => print("[INFO] {msg}"),
    Log::warn(msg)  => print("[WARN] {msg}"),
    Log::error(msg) => eprint("[ERROR] {msg}"),
  }
}

Um wrapper que remove o efeito da assinatura ao instalar handlers padrão. A função interna usa Log; quem chama with_default_logger(...) não precisa pensar em logging.


7. Comparação com alternativas #

7.1 vs. Dependency injection clássica #

rust
// Trait DI
trait Deps {
    fn fs(&self) -> &dyn Fs;
    fn log(&self) -> &dyn Logger;
}
fn parse(deps: &dyn Deps) -> Config { ... }

Cada nova dep:

  • nova fn no trait
  • nova implementação em todos os structs Deps
  • todos os tests precisam fabricar um Deps mockado

Com efeitos: with E + F substitui o trait inteiro. Cada nova dep é uma cláusula extra no handle da borda.

7.2 vs. Singletons / módulos globais #

typescript
import { fs } from 'std/fs'
function parse() { return fs.read('cfg.toml') }

Não testável sem mocks de módulo (jest.mock(...), monkey-patching). Vaza estado entre testes em paralelo. Não tem como sandboxar um chamador específico.

Com efeitos: a chamada é parametrizada por construção. Cada handle é seu próprio mundo.

7.3 vs. Monads (Haskell-style) #

haskell
parseConfig :: IO Config
parseConfig = do
  raw <- readFile "cfg.toml"
  return (parse raw)

IO polui toda função que toca. Combinar IO + Reader + State + Either exige transformers ou mtl — boilerplate famoso.

Com efeitos: combinar é with E1 + E2 + E3. Sem lift, sem stack de monads.

7.4 vs. Callbacks / hooks #

javascript
function parse(opts = {}) {
  const fs = opts.fs ?? require('fs')
  return fs.readFileSync('cfg.toml')
}

Cada dep é um campo opcional. A assinatura não diz o que é necessário. Defaults vazam por baixo dos panos.

Com efeitos: a assinatura é documentação executável. Esquecer um handler é erro de compilação em v1 (TE803) ou erro de runtime se o handle não cobrir todas as operações.

7.5 vs. Async/await viral #

typescript
async function a() { await b() }
async function b() { await c() }

async se propaga para baixo até o entry-point. Função pura que vira async força reescrita de toda cadeia.

Com efeitos (v2+): with Async é só uma anotação que se acumula. Adicionar with Logger no meio da cadeia não muda a estrutura sintática.

7.6 Tabela resumo #

Abordagem Testável Sandbox Composição Boilerplate
Parâmetros explícitos sim não não (assinatura cresce) alto
Singletons não não sim zero
Trait DI sim não parcial (interfaces grandes) alto
Mocks runtime frágil não sim médio
Monads sim sim parcial (stacks/lift) alto
Algebraic effects sim sim sim (com +) baixo

8. Modelo de runtime #

A v1 usa um handler-stack dinâmico (semelhante a OCaml 5 / Effect.Deep):

  • Um global __zolo_effect_stack é uma lista de "frames". Cada frame é uma tabela { "Effect::op" = fn(...) ... }.
  • handle expr with { ... } empilha o frame, executa expr num pcall, desempilha — mesmo em caso de erro.
  • perform Effect::op(args) percorre o stack do topo para a base, encontra a cláusula, chama-a com args, devolve o resultado.
  • Sem multi-shot continuations: o handler devolve o valor e o perform recebe-o (one-shot resume implícito).
parse_config()             handle{Production}            handle{Test}
   |                            |                              |
   `-- perform IO::read("c") -->|                              |
                                |--> Real::read("c") -> fs ----&#39;
                                <-- "[server]\nport=8080"

parse_config()             handle{Test}
   |                            |
   `-- perform IO::read("c") -->|
                                |--> Mock::read(_) -> stub
                                <-- "[server]\nport=8080"

Custo #

  • Cada perform é O(profundidade do stack). Tipicamente 1–3.
  • Cada handle é uma alocação de tabela + um pcall.
  • Comparado a try/catch: ~2× mais caro, ainda barato. Em hot loops, evite.

Detalhes em ADR 0003 §3.


9. Quando NÃO usar #

  • Hot loops — cada perform é uma busca no stack + chamada indireta. Para milhões de chamadas/s, prefira passar a dep como parâmetro.
  • Funções puras — matemática, transformação de strings, parsing. Efeitos só engrossam a assinatura.
  • Operação com retorno de erro único — quando a "capability" tem só uma chamada que falha (parse de uma string), Result<T, E> é mais simples.
  • App pequeno sem testes — efeitos brilham quando você tem mocks/replays/dry-run. Para um script de 50 linhas sem testes, é overkill.
  • Equipe sem familiaridade — efeitos são novos pra muita gente. Não introduza num codebase grande sem alinhamento + um exemplo de referência.

10. Capabilities — sandbox de plugins #

Plugins declaram quais efeitos requerem no zolo.toml:

toml
[plugin]
name = "my-yaml-loader"
version = "0.1.0"
requires-effects = ["IO::read"]
provides-effects = []

Em v1 isto é declaração documental. Em v2, a chamada de plugin é tipada. Em v3, runtime injeta o stack conforme política do projeto:

bash
zolo run --no-effect=IO::write app.zolo   # v3+

O plugin tenta perform IO::write(...) → erro capability denied: IO::write. Sandbox sem VM separada, sem syscall isolation. Ver ADR 0003 §5.


11. Receita: substituindo async/await por efeitos

Hoje:

async fn fetch_homepage() -> str {
  let r = await http.get("https://example.com")
  return r.body
}

Equivalente com efeitos (v2+):

fn fetch_homepage() with Async + Http -> str {
  let r = perform Http::get("https://example.com")
  return r.body
}

A diferença prática:

  • async fn força tudo até o topo a ser async.
  • with Async é uma anotação que pode ser "removida" por um wrapper que instala um event-loop handler. O event-loop vira biblioteca, não keyword.

A migração planeada (ver §13) preserva async/await como açúcar sintático — você não precisa reescrever código existente.


12. Type checking — o que está em v1 #

O verificador estrito de efeitos (v1) emite erros de compilação para as condições abaixo. Nenhuma delas é apenas aviso.

Cenário Diagnóstico v1
perform E::op numa fn que não declara with E e não está dentro de um handle que cobre E Erro TE800
with E referência a um efeito não declarado (salvo import use plugin::prelude::*) Erro TE801
Chamador invoca fn com efeitos que o chamador não declara e não estão cobertos por handle Erro TE802
handle expr with { ... } não fornece arm para toda operação de todo efeito requerido Erro TE803
Arm do handler referencia operação inexistente no efeito Erro TE804
Arm do handler tem número diferente de parâmetros que a assinatura da operação Erro TE805
Dois arms para a mesma operação no mesmo handle Erro TE806
perform em runtime sem handler instalado Erro de runtime: unhandled effect: ...

A tipagem do valor de retorno de perform e dos parâmetros de handler permanece pendente para v1.5. Row polymorphism chega em v2. Para a lista completa de códigos com descrições, ver §16 — Códigos de diagnóstico.


13. Roadmap v1 → v2 #

A v2 introduz row polymorphism estilo Koka:

fn parse() -> Config with <IO | r>           // IO + qualquer outro efeito r
fn run<r>(action: () -> a with <IO | r>) -> a with <r>

Onde r é uma "linha" de efeitos polimórfica — você pode passar uma função que usa IO + Logger para um wrapper que só consome IO, e o Logger "atravessa" pelo r.

Custos estimados (ADR 0003 §4):

  • 6 semanas pesquisa (algoritmo de inferência)
  • 3 meses MVP (parser + checker + lowering)
  • 1 mês integração (LSP + diagnostics)

Marcos:

  • v1: verificador estrito lançado — TE800/TE801 promovidos a erros, TE802–TE806 adicionados.
  • v1.5: tipagem de retorno de perform e parâmetros de handler.
  • v2.0: row polymorphism + inferência local.
  • v2.1: Async, Panic, Iter reescritos como efeitos da stdlib.
  • v3.0: capabilities runtime (zolo run --no-effect).

14. Limitações conhecidas (v1) #

  • Sem multi-shot resume. Algoritmos como backtracking, exceptions resumáveis, iteradores não-lineares não são expressáveis. Workaround: use for ... in iter + escolha de coleção.
  • Sem inferência. Você declara with E à mão em todas as funções que usam E.
  • Exaustividade verificada em compilação (TE803). Handlers incompletos são erro estático. Tipagem dos valores de retorno permanece para v1.5.
  • Backend nativo não suportado. crates/zolo-lang/tests/e2e/18_effects/ tem @skip-native. Use zolo run (VM).
  • Sem instrumentação no profiler. Custo é mensurável mas sem hook em zolo-tools ainda.

15. Comparação com outras linguagens #

Sistema Resume Type tracking Maturidade
Zolo v1 One-shot implícito Verificador estrito (TE800–TE806), erros de compilação Estável
Zolo v2 (planeado) One-shot implícito Row polymorphism completo
Koka (Microsoft) Multi-shot + abort Row polymorphism completo Pesquisa madura
Eff Multi-shot Tipos efeito explícitos Pesquisa
OCaml 5 (Effect.Deep) One-shot Sem checagem estática Estável (5.0+)
Roc Implícito via Task Inferido Beta
Unison Abilities (efeitos reordenados) Inferido Beta

A escolha do Zolo é OCaml 5 v1 → Koka v2: começa com semântica simples e bem-testada, evolui para tracking estrutural quando o type-system maturar.


16. Códigos de diagnóstico #

Código Gatilho
TE800 Uma função usa perform Foo::op sem declarar with Foo e não está dentro de um handle que cobre Foo.
TE801 Uma função declara with Foo mas nenhum effect Foo { ... } está em escopo. (Soft / aviso apenas quando há import use plugin::prelude::* ativo.)
TE802 Um chamador invoca uma função cuja linha with contém um efeito que o chamador não declara e não está coberto por um handle envolvente.
TE803 Um bloco handle expr with { ... } não fornece um arm para cada operação de cada efeito requerido por expr.
TE804 Um arm do handler referencia uma operação que não existe no efeito nomeado.
TE805 Um arm do handler tem número de parâmetros diferente do que a assinatura da operação declara.
TE806 Um bloco handle tem dois arms para a mesma operação de efeito.

17. Referência rápida #

// Declarar um efeito
effect Name {
  fn op1(p: T) -> R
  fn op2(p1: T1, p2: T2)         // sem retorno -> nil
}

// Função que usa efeitos
fn f(x: int) with E1 + E2 -> R { ... }

// Invocar uma operação
perform Name::op1(arg)

// Instalar handlers
handle expr with {
  E1::op1(p)     => <expr usando p>,
  E2::op2(a, b)  => <expr ou bloco>,
}

// Wrappers que removem efeitos da assinatura
fn wrap<T>(action: fn() -> T with E) -> T {
  return handle action() with { E::op(p) => <impl> }
}

18. Onde aprender mais #

Leitura externa #


Próximo: veja Schemas — o irmão "puramente estrutural" dos efeitos: validação de dados sem capacidades.

enespt-br