If you inspect the success type of program
in your IDE you actually don't see Pokemon
but instead an object with the properties we defined inside Pokemon
.
This issue derives from how we defined the schema. With Schema.Struct
we don't get an opaque type.
An opaque type is a type whose underlying structure is hidden. It's like a black box: you know what it represents, but not its internal details.
Using
Schema.Struct
we instead get the full structure of the type!
Define schema using class and Schema.Class
We can instead use a class
with Schema.Class
, which allows to define the shape and export an opaque type at the same time:
- Define a
class
that extendsSchema.Class
- The type parameter is the same as the
class
name (<Pokemon>
) - The
string
parameter is the_tag
of the schema - The second parameter is the shape of the schema
class Pokemon extends Schema.Class<Pokemon>("Pokemon")({
// 👇 Parameters are the same as `Schema.Struct`
id: Schema.Number,
order: Schema.Number,
name: Schema.String,
height: Schema.Number,
weight: Schema.Number,
}) {}
Now Pokemon
can be used directly as a type:
class Pokemon extends Schema.Class<Pokemon>("Pokemon")({
id: Schema.Number,
order: Schema.Number,
name: Schema.String,
height: Schema.Number,
weight: Schema.Number,
}) {}
const extractId = (pokemon: Pokemon) => pokemon.id;
Since we are now working with a class
we can also attach methods to it:
class Pokemon extends Schema.Class<Pokemon>("Pokemon")({
id: Schema.Number,
order: Schema.Number,
name: Schema.String,
height: Schema.Number,
weight: Schema.Number,
}) {
public get formatHeight(): string {
return `${this.height}cm`;
}
}
We also solved the issue with the opaque type in the IDE. With Schema.Class
when you inspect the response type you will see Pokemon
instead of all the properties:
I use Schema.Class
to define my schemas all the times when possible.
Schema.Class
cannot be used for non-object schemas, like union types or primitive types.
/// `Schema.Literal` without `Class` for a union of values
const PokemonType = Schema.Literal("fire", "water", "grass");
/// ⛔️ Cannot use `Schema.Class` ⛔️
class PokemonType extends Schema.Class<PokemonType>("PokemonType")(Schema.Literal("fire", "water", "grass")) {}
This is our app now:
import { Schema } from "effect";
import { Data, Effect } from "effect";
/** Schema definition **/
class Pokemon extends Schema.Class<Pokemon>("Pokemon")({
id: Schema.Number,
order: Schema.Number,
name: Schema.String,
height: Schema.Number,
weight: Schema.Number,
}) {}
/** Errors **/
class FetchError extends Data.TaggedError("FetchError")<{}> {}
class JsonError extends Data.TaggedError("JsonError")<{}> {}
/** Implementation **/
const fetchRequest = Effect.tryPromise({
try: () => fetch("https://pokeapi.co/api/v2/pokemon/garchomp/"),
catch: () => new FetchError(),
});
const jsonResponse = (response: Response) =>
Effect.tryPromise({
try: () => response.json(),
catch: () => new JsonError(),
});
const decodePokemon = Schema.decodeUnknown(Pokemon);
const program = Effect.gen(function* () {
const response = yield* fetchRequest;
if (!response.ok) {
return yield* new FetchError();
}
const json = yield* jsonResponse(response);
return yield* decodePokemon(json);
});
/** Error handling **/
const main = program.pipe(
Effect.catchTags({
FetchError: () => Effect.succeed("Fetch error"),
JsonError: () => Effect.succeed("Json error"),
ParseError: () => Effect.succeed("Parse error"),
})
);
/** Running effect **/
Effect.runPromise(main).then(console.log);
When we run this we get the following:
> effect-getting-started-course@1.0.0 dev
> tsx src/index.ts
{ id: 445, order: 570, name: 'garchomp', height: 19, weight: 950 }
We added quite some lines of code to the original plain-typescript solution. Nonetheless, this allows us to go from the "happy path" to full error handling and schema validation.
Furthermore, this app is completely type-safe. Nothing can go wrong without us noticing, since the compiler will report any error and prevent the app from launching.
That's awesome! In practice it means no more runtime errors.
We have a saying: "If it compiles it works".
We are not satisfied yet. We are still hardcoding values like the API url (https://pokeapi.co
):
const fetchRequest = Effect.tryPromise({
try: () => fetch("https://pokeapi.co/api/v2/pokemon/garchomp/"),
catch: () => new FetchError(),
});
This causes issues when testing and maintaining the app:
- How do we change the url for testing?
- We don't want to copy-paste the url every time we use it
- Hard to refactor if the url changes for whatever reason
This makes difficult to test and organize the code as the app scales. There is a solution: environmental variables.
Let's see how we can manage them in effect using Config
!