Tagscript

Moving a parser to Effect

What changes when you rewrite a classic parser for the Effect entry point, line by line.

Nothing forces you to move. The classic entry point is not deprecated and is faster. Move a parser when it needs something a classic parser cannot have: a service, or an error the caller must handle.

The table

ClassicEffect
class X extends BaseParserdefinePlugin({ ... })
super(names, reqParam, reqPayload)names, requiredParameter, requiredPayload
parse(ctx): string | nullparse: (ctx) => Effect.succeed(...)
async parse and awaitparse: Effect.fnUntraced(function* (ctx) { ... })
throw new TemplateError(msg, tag)return yield* new TemplateError({ message, tag })
throw new StopSignal(msg)return yield* new StopSignal({ message })
needs a client, so IKeyValues augmentingyield* MyService, declared in R
ctx.response.actions.foo = xunchanged
Math.random()yield* Random.nextIntBetween(0, n)
Date.now()yield* DateTime.now
run(msg, vars, charLimit)run(msg, { seedVariables }) and CharLimit service

A parser with nothing special

Most parsers are a name and an expression. They do not need a generator:

classic
export class UpperParser extends BaseParser implements IParser {
	public constructor() {
		super(['upper'], false, true);
	}

	public parse(ctx: Context) {
		return ctx.tag.payload!.toUpperCase();
	}
}
effect
export const upperParser = definePlugin({
	names: ['upper'],
	requiredPayload: true,
	parse: (ctx) => Effect.succeed(ctx.tag.payload!.toUpperCase()),
});

Use Effect.succeed for a pure result, Effect.sync when it writes to the response, and Effect.fnUntraced when the body has to yield*. Reaching for a generator that never yields costs an allocation per tag for nothing.

A parser that needs something

This is the reason the entry point exists. Declare the service and yield it:

class Translator extends Context.Service<Translator, {
	readonly to: (text: string, locale: string) => Effect.Effect<string, TranslationFailed>;
}>()('myapp/Translator') {}

export const translateParser = definePlugin({
	names: ['translate'],
	requiredParameter: true,
	requiredPayload: true,
	parse: Effect.fnUntraced(function* (ctx) {
		const translator = yield* Translator;
		return yield* translator.to(ctx.tag.payload!, ctx.tag.parameter!);
	}),
});

Its type is Parser<TranslationFailed, Translator>. Register it and the interpreter carries both, so the call site provides the layer and handles the error, or it does not compile.

On the classic entry point the same parser has to reach the translator through IKeyValues, which is global, untyped by construction, and silently undefined if the host forgets to pass it.

Errors

A classic parser throws. An Effect parser fails, and what it fails with decides what happens.

classic
if (Number.isNaN(seconds)) throw new TemplateError('cooldown needs a number of seconds', 'cooldown');
effect
if (Number.isNaN(seconds)) {
	return yield* new TemplateError({ message: 'cooldown needs a number of seconds', tag: 'cooldown' });
}

return yield* rather than a bare yield*, so TypeScript knows the function stops there.

A TemplateError still renders in place of the tag and lands on response.errors. Anything else you declare reaches the caller instead, which is the point. Keep those errors specific enough to catch by tag.

Randomness and time

Both were untestable before. A classic parser calling Math.random() or Date.now() gives a different answer every run and nothing can pin it.

parse: Effect.fnUntraced(function* (ctx) {
	const options = split(ctx.tag.payload!, true);
	return options[yield* Random.nextIntBetween(0, options.length - 1)];
});

Random.nextIntBetween includes both bounds, unlike Math.floor(Math.random() * n). Getting that wrong is how {random:a,b} ends up returning nothing, so mind the - 1.

Seed it in a test with Random.withSeed(seed)(effect), and pin the clock with TestClock.setTime(millis).

Configuration

run took six positional arguments. Now it takes two, and the rest are services provided once for the application:

ts.run(template, { seedVariables, keyValues }).pipe(
	Effect.provideService(CharLimit, 2_000),
	Effect.provideService(TagLimit, 500),
);

Not moving everything

fromClassic lifts a classic parser as it is, actions and variables included. toClassic goes the other way for any parser that needs no services. toPromise runs a whole Effect interpreter from code that is not on Effect.

Mixing is fine and expected. Move the parsers that gain something and leave the rest.

Last updated on

On this page

Edit on Github