DEV Community

Cover image for How we migrated our codebase from fp-ts to Effect
Laure
Laure

Posted on • Originally published at Medium

How we migrated our codebase from fp-ts to Effect

Summary

At Inato, we migrated from fp-ts to Effect in early 2024. Given our substantial codebase (around 500k lines of typescript code), we needed a way of ensuring any new code could be written using Effect while allowing existing fp-ts code to coexist. We achieved this goal in just two months, dedicating around 10% of our time to it. In this article, you will find our detailed migration strategy, the helpers we developed (which you can find in this repository), and how we ensured a smooth transition of our codebase.

Migrate to Effect, why?

At Inato we were very motivated early on to adopt functional programming, so we started using fp-ts in our codebase at the beginning of 2020. If you want to know more about this, have a look at Our journey to functional programming.

Let’s now get to the heart of the matter: at the beginning of this year, we officially decided to switch to Effect! Why?

  • The main maintainer of fp-ts (gcanti 👋) joined the Effect team which presumably means less active development on the fp-ts side and positions Effect as a rather obvious next step.
  • Because of the learning curve associated with fp-ts and the lack of documentation. Developers who joined Inato in the last years have frequently mentioned it: learning fp-ts is not really straightforward. This is a strong point for Effect with top-notch documentation and a lot of resources for training.
  • For even more reasons, visit the Effect website which compares fp-ts and Effect!

Migrating our codebase to Effect is a great goal, but doing it turned out to be more challenging and required careful planning. We also wanted to limit the time spent on this project, so we agreed on a 2.5-month deadline. With all this in mind, we came up with the following strategy.

The Migration Strategy

First of, here’s a representation of our server-side codebase: we have use cases that represent our business actions, these use cases have multiple dependencies (services, repositories, etc. — we’ll refer to them as ports), and we also have runners that will execute our use cases:

Our codebase

When we started the migration we had around 400 use cases and 80 ports and their adapters to migrate.

Our objective for this migration was clear: by the end of our 2.5-month window, any new use case or port will be written using Effect. To have a smooth transition that would allow us to have fp-ts and Effect code cohabitating, we came up with the following plan:

  1. Ensure our ports return ReaderTaskEither to facilitate the transition to Effect [*]
  2. Create Effect proxies of our ports: only one implementation in fp-ts, but the ability to use an fp-ts “real” version and an Effect proxy version of each port
  3. Start (re)writing use cases in Effect
  4. Create fp-ts proxies of Effect use cases
  5. Start (re)writing ports in Effect
  6. Create fp-ts proxies of Effect ports: at this point, we would already have fulfilled our objective of writing new use cases and ports with Effect. But we wanted to go the extra mile to have the full flow covered!
  7. Be able to run both Effect and fp-ts use cases

Migration steps

[*] ReaderTaskEither (we will refer to it as RTE later on) was a prerequisite to facilitate the migration to Effect. Why? Conceptually, a ReaderTaskEither can be represented as follows:

ReaderTaskEither<R, E, A>  
= Reader<R, TaskEither<E, A>>  
= (context: R) => () => Promise<Either<E, A>>
Enter fullscreen mode Exit fullscreen mode

If we look at the representation of an effect given on the official Effect website, we can see that these are very similar concepts (which is something that we will leverage during our migration):

Effect<A, E, R> ~ (context: Context<R>) => E | A
Enter fullscreen mode Exit fullscreen mode

The Migration Process

Let’s deep dive into the code now! Here are the steps we are going to follow:

To illustrate our migration process, we will focus on an example program that is representative of how our codebase is organized.

Note: all the code and helpers that will be presented are available in 👉 this repository 👈

The program to migrate

Let say that our domain model is composed of a simple Foo class:

// domain.ts  
export class Foo {  
  constructor(readonly id: string) {}  
  static make = (id = "random-id") => new Foo(id);  
}
Enter fullscreen mode Exit fullscreen mode

We define a repository port to get and store a Foo:

// FooRepository.ts  
export interface FooRepository {  
  getById: (id: string) => RTE<unknown, Error, Foo>;  
  store: (foo: Foo) => RTE<unknown, Error, void>;  
}  

export interface FooRepositoryAccess {  
  fooRepository: FooRepository;  
}  
export declare const FooRepository: {  
  getById: (id: string) => RTE<FooRepositoryAccess, Error, Foo>;  
  store: (foo: Foo) => RTE<FooRepositoryAccess, Error, void>;  
};  
export declare const makeFooRepository: () => Promise<FooRepository>;
Enter fullscreen mode Exit fullscreen mode

Note:

  • We follow the module pattern when defining the FooRepositoryAccess interface to enable context aggregation when composing multiple ReaderTaskEither:
declare const a: RTE<{ serviceA: ServiceA },never,void>  
declare const b: RTE<{ serviceB: ServiceB },never,void>  
const ab: RTE<{ serviceA: ServiceA; serviceB: ServiceB },never,void>   
    = rte.flatMap(a,() => b)
Enter fullscreen mode Exit fullscreen mode
  • We define a companion object FooRepository that exposes the same methods as the repository itself, except that they each require a context with FooRepositoryAccess. This makes for more concise code later on:
const theLongWay: RTE<FooRepositoryAccess, Error, Foo> = pipe(  
 rte.ask<FooRepositoryAccess>(),  
 rte.flatMap(({ fooRepository }) => fooRepository.getById('id'))  
);  

const theEasyWay: RTE<FooRepositoryAccess, Error, Foo>   
 = FooRepository.getById('id')
Enter fullscreen mode Exit fullscreen mode

We also define a service port to transform a Foo:

// TransformFooService.ts  
export interface TransformFooService {  
  transform: (foo: Foo) => RTE<unknown, Error, Foo>;  
}  

export interface TransformFooServiceAccess {  
  transformFooService: TransformFooService;  
}  

export declare const TransformFooService: {  
  transform: (foo: Foo) => RTE<TransformFooServiceAccess, Error, Foo>;  
};  

declare const makeTransformFooService: () => Promise<TransformFooService>;
Enter fullscreen mode Exit fullscreen mode

Next we can write two use cases: one to create a new Foo , and another one to transform a Foo:

// usecases.ts  
export const createFooUseCase = (id:string) =>   
 pipe(  
   rte.of(Foo.make(id)),  
   rte.tap(FooRepository.store)  
 );  

export const transformFooUseCase = (id: string) =>  
  pipe(  
    FooRepository.getById(id),  
    rte.flatMap(TransformFooService.transform),  
    rte.flatMap(FooRepository.store)  
  );
Enter fullscreen mode Exit fullscreen mode

Finally, we can write our main that will create all the port adapters and invoke our use cases:

// index.ts  
const main = async () => {  
  const fooRepository = await makeFooRepository();  
  const transformFooService = await makeTransformFooService();  
  await createFooUseCase("my-foo-id")({  
    transformFooService,  
    fooRepository,  
  })();  
  await transformFooUseCase("my-foo-id")({  
    transformFooService,  
    fooRepository,  
  })();  
};  
main();
Enter fullscreen mode Exit fullscreen mode

Create Effect proxies of the ports

This step consists in generating new companion objects FooRepository and TransformFooService for our ports that are exposing an Effect version of the member methods.

First we rename the companion objects, adding a Fpts suffix:

// FooRepository.ts  
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶{̶  
export declare const FooRepositoryFpts: {  
  getById: (id: string) => RTE<FooRepositoryAccess, Error, Foo>;  
  store: (foo: Foo) => RTE<FooRepositoryAccess, Error, void>;  
};  

// TransformFooService.ts  
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶{̶  
export declare const TransformFooServiceFpts: {  
  transform: (foo: Foo) => RTE<TransformFooServiceAccess, Error, Foo>;  
};  

// usecases.ts  
export const createFooUseCase = (id:string) =>   
 pipe(  
   rte.of(Foo.make(id)),  
   r̶t̶e̶.̶t̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶s̶t̶o̶r̶e̶)̶  
   rte.tap(FooRepositoryFpts.store)  
 );  

export const transformFooUseCase = (id: string) =>  
  pipe(  
    F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶g̶e̶t̶B̶y̶I̶d̶(̶i̶d̶)̶,̶  
    r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶.̶t̶r̶a̶n̶s̶f̶o̶r̶m̶)̶,̶  
    r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶s̶t̶o̶r̶e̶)̶  
    FooRepositoryFpts.getById(id),  
    rte.flatMap(TransformFooServiceFpts.transform),  
    rte.flatMap(FooRepositoryFpts.store)  
  );
Enter fullscreen mode Exit fullscreen mode

Then we use the portToEffect helper function to generate the Effect companion objects from the previous companion objects:

// FooRepository.ts  
export const FooRepositoryTag = Context.GenericTag<FooRepository>(  
 "FooRepository"  
);  
export const FooRepository = portToEffect(FooRepositoryFpts, {  
  fooRepository: FooRepositoryTag,  
}); // { getById: (id: string) => Effect<Foo, Error, FooRepository> ... }  

// TransformFooService.ts  
export const TransformFooServiceTag = Context.GenericTag<TransformFooService>(  
  "TransformFooService"  
);  
export const TransformFooService = portToEffect(TransformFooServiceFpts, {  
  transformFooService: TransformFooServiceTag,  
}); // { transform: (foo: Foo) => Effect<Foo, Error, TransformFooService> }
Enter fullscreen mode Exit fullscreen mode

Rewrite a use case in Effect

At this point we can start using our newly generated Effect companion objects to rewrite the transformFooUseCase use case in Effect. Note that we voluntarily leave the createFooUseCase use case as is to simulate a migration that is ongoing, as opposed to a “big-bang” migration where we would convert all of our use cases to Effect in one go (much harder and riskier).

// usecases.ts  
export const transformFooUseCase = (id: string) =>  
  pipe(  
    F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶.̶g̶e̶t̶B̶y̶I̶d̶(̶i̶d̶)̶,̶  
    r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶.̶t̶r̶a̶n̶s̶f̶o̶r̶m̶)̶,̶  
    r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶.̶s̶t̶o̶r̶e̶)̶  
    FooRepository.getById(id),  
    Effect.flatMap(TransformFooService.transform),  
    Effect.flatMap(FooRepository.store)  
  ); // Effect<void, Error, TransformFooService | FooRepository>
Enter fullscreen mode Exit fullscreen mode

Since we don’t want to impact our main program yet, we must maintain an fp-ts version of this use case, for backward compatibility. We can generate it from the Effect version thanks to the functionToFpts helper function:

// usecases.ts  
export const transformFooUseCaseFpts = functionToFpts(transformFooUseCase, {  
  fooRepository: FooRepositoryTag,  
  transformFooService: TransformFooServiceTag,  
}); // RTE<TransformFooServiceAccess & FooRepositoryAccess, Error, void>  

// index.ts  
const main = async () => {  
  const fooRepository = await makeFooRepository();  
  const transformFooService = await makeTransformFooService();  
  await createFooUseCase("my-foo-id")({  
   transformFooService,  
    fooRepository,  
  })();  
  a̶w̶a̶i̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶U̶s̶e̶C̶a̶s̶e̶(̶"̶m̶y̶-̶f̶o̶o̶-̶i̶d̶"̶)̶(̶{̶  
  await transformFooUseCaseFpts("my-foo-id")({  
    transformFooService,  
    fooRepository,  
  })();  
};  
main();
Enter fullscreen mode Exit fullscreen mode

Convert ports to Effect

Next we convert our FooRepository port to Effect directly:

// FooRepository.ts  
export interface FooRepository {  
  g̶e̶t̶B̶y̶I̶d̶:̶ ̶(̶i̶d̶:̶ ̶s̶t̶r̶i̶n̶g̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶  
  s̶t̶o̶r̶e̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶v̶o̶i̶d̶>̶;̶  
  getById: (id: string) => Effect.Effect<Foo, Error>;  
  store: (foo: Foo) => Effect.Effect<void, Error>;  
}
Enter fullscreen mode Exit fullscreen mode

We can now generate the Effect companion object using Effect.serviceFunctions:

// FooRepository.ts  
e̶x̶p̶o̶r̶t̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶=̶ ̶p̶o̶r̶t̶T̶o̶E̶f̶f̶e̶c̶t̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶,̶ ̶{̶  
̶ ̶ ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶T̶a̶g̶,̶  
̶}̶)̶;̶  
export const FooRepository = Effect.serviceFunctions(FooRepositoryTag);
Enter fullscreen mode Exit fullscreen mode

Finally, for backward compatibility, we must maintain the fp-ts companion object. We can generate it using the portToFpts helper function:

// FooRepository.ts  
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶:̶ ̶{̶  
̶ ̶ ̶g̶e̶t̶B̶y̶I̶d̶:̶ ̶(̶i̶d̶:̶ ̶s̶t̶r̶i̶n̶g̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶  
̶ ̶ ̶s̶t̶o̶r̶e̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶v̶o̶i̶d̶>̶;̶  
̶}̶;̶  

export const FooRepositoryFpts = portToFpts(FooRepository, {  
  fooRepository: FooRepositoryTag,  
}); // { getById: (id: string) => RTE<FooRepositoryAccess, Error, Foo>; ... }
Enter fullscreen mode Exit fullscreen mode

We do the same for the TransformFooService port:

// TransformFooService.ts  
export interface TransformFooService {  
  t̶r̶a̶n̶s̶f̶o̶r̶m̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶  
  transform: (foo: Foo) => Effect<Foo, Error>;  
}  

e̶x̶p̶o̶r̶t̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶ ̶=̶ ̶p̶o̶r̶t̶T̶o̶E̶f̶f̶e̶c̶t̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶,̶ ̶{̶  
̶ ̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶T̶a̶g̶,̶  
̶}̶)̶;̶  
export const TransformFooService = Effect.serviceFunctions(  
 TransformFooServiceTag  
);  

e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶:̶ ̶{̶  
̶ ̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶  
̶}̶;̶  
export const TransformFooServiceFpts = portToFpts(TransformFooService, {  
  fooRepository: FooRepositoryTag,  
}); // { transform: (foo: Foo) => RTE<unknown, Error, Foo>; }
Enter fullscreen mode Exit fullscreen mode

Note that we have not changed our main in this step and it can still be run without a problem.

Use ManagedRuntime to run Effect usecases

In order to run the transformFooUseCase as an Effect, we must be able to provide our ports via Layers:

// FooRepository.ts  
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶m̶a̶k̶e̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶(̶)̶ ̶=̶>̶ ̶P̶r̶o̶m̶i̶s̶e̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶>̶;̶  
export declare const FooRepositoryLive: Layer.Layer<FooRepository>;  

// TransformFooService.ts  
d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶m̶a̶k̶e̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶(̶)̶ ̶=̶>̶ ̶P̶r̶o̶m̶i̶s̶e̶<̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶>̶;̶  
declare const TransformFooServiceLive: Layer.Layer<TransformFooService>;
Enter fullscreen mode Exit fullscreen mode

Next we can create a ManagedRuntime and extract all the ports from the runtime context using the contextToFpts helper:

// index.ts  
const main = async () => {  
  c̶o̶n̶s̶t̶ ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶=̶ ̶a̶w̶a̶i̶t̶ ̶m̶a̶k̶e̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶(̶)̶;̶  
  c̶o̶n̶s̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶ ̶=̶ ̶a̶w̶a̶i̶t̶ ̶m̶a̶k̶e̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶(̶)̶;̶  
  const runtime = ManagedRuntime.make(  
    Layer.mergeAll(FooRepositoryLive, TransformFooServiceLive)  
  );  
  const { context } = await runtime.runtime();  
  const { fooRepository, transformFooService } = contextToFpts(context, {  
    fooRepository: FooRepositoryTag,  
    transformFooService: TransformFooServiceTag,  
  });  
  await createFooUseCase("my-foo-id")({  
   transformFooService,  
    fooRepository,  
  })();  
  await transformFooUseCaseFpts("my-foo-id")({  
    transformFooService,  
    fooRepository,  
  })();  
};  
main();
Enter fullscreen mode Exit fullscreen mode

Finally, we can use the runtime to run the Effect transformFooUseCase:

// index.ts  
const main = async () => {  
  const runtime = ManagedRuntime.make(  
    Layer.mergeAll(FooRepositoryLive, TransformFooServiceLive)  
  );  
  const { context } = await runtime.runtime();  
  const { fooRepository, transformFooService } = contextToFpts(context, {  
    fooRepository: FooRepositoryTag,  
    transformFooService: TransformFooServiceTag,  
  });  
  await createFooUseCase("my-foo-id")({  
   transformFooService,  
    fooRepository,  
  })();  
  a̶w̶a̶i̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶U̶s̶e̶C̶a̶s̶e̶F̶p̶t̶s̶(̶"̶m̶y̶-̶f̶o̶o̶-̶i̶d̶"̶)̶(̶{̶
   ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶,̶  
   ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶,̶  
  }̶)̶(̶)̶;̶  
 await runtime.runPromise(transformFooUseCase("my-foo-id"));
};  
main();
Enter fullscreen mode Exit fullscreen mode

Note that, once again, we left the createFooUseCase use case as is to show that we can be in a hybrid state where only part of the use cases have been migrated to Effect.

Bonus: simplify fp-ts ↔ effect tag mapping management

All of the helpers we have used throughout this migration require a mapping object to go from the key name of the fp-ts port Access interface (eg transformFooService of TransformFooServiceAccess) to the Tag of the corresponding Effect port. For example:

contextToFpts(context, {  
  fooRepository: FooRepositoryTag,  
  transformFooService: TransformFooServiceTag,  
});
Enter fullscreen mode Exit fullscreen mode

This mapping is essential for all the helpers to work correctly. It is not ideal to have to craft them like that all the time. To help us with that, we introduce:

const FptsConvertibleId = Symbol();  
interface FptsConvertible<T extends string> {   
  [FptsConvertibleId]: T;  
}
Enter fullscreen mode Exit fullscreen mode

We can now embed this conversion information at the type level of our ports:

// FooRepository.ts  
export interface FooRepository extends FptsConvertible<"fooRepository"> {  
  getById: (id: string) => Effect.Effect<Foo, Error>;  
  store: (foo: Foo) => Effect.Effect<void, Error>;  
}  

// TransformFooService.ts  
export interface TransformFooService   
  extends FptsConvertible<"transformFooService"> {  
  transform: (foo: Foo) => Effect<Foo, Error>;  
}
Enter fullscreen mode Exit fullscreen mode

The first thing we can do with this is to simplify the definition of Access interfaces using a type helper FptsAccess:

// FooRepository.ts  
export interface FooRepositoryAccess extends FptsAccess<FooRepository> {}  

// TransformFooService.ts  
export interface TransformFooServiceAccess   
  extends FptsAccess<TransformFooService> {}
Enter fullscreen mode Exit fullscreen mode

And we can also define smaller atomic mapping objects using a new helper getFptsMapping:

// FooRepository.ts  
const FooRepositoryFptsMapping = getFptsMapping(  
  FooRepositoryTag,  
  "fooRepository"  
); // { fooRepository: FooRepositoryTag }  

// TransformFooService.ts  
const TransformFooServiceFptsMapping = getFptsMapping(  
  TransformFooServiceTag,  
  "transformFooService"  
); // { transformFooService: TransformFooServiceTag }
Enter fullscreen mode Exit fullscreen mode

Note: It looks like we are once again typing the key "fooRepository" or "transformFooService" but in fact, the function getFptsMapping is type-safe so that given FooRepositoryTag as first argument, only the string "fooRepository" is valid as second argument. So your code editor will autocomplete it for you. Moreover, the compiler will break if you change the definition in the FptsConvertible so it is not really an additional burden.

We can now combine these two mapping objects when calling contextToFpts or any other helper:

contextToFpts(context, {  
  f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶T̶a̶g̶,̶  
  t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶T̶a̶g̶,̶  
  ...FooRepositoryFptsMapping,  
  ...TransformFooServiceFptsMapping,  
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Our objective of being able to write any new use case or port using Effect was accomplished in 2 months (working around 10% of our time on it)!

Teamwork was definitely a big part of this success: first, we have to mention Stephane Ledorze as he migrated all our repositories single-handedly and gave us great advice on how to define our migration strategy. We handled the rest with the whole team during dedicated “tech sessions” that we do every Wednesday afternoon at Inato: during those sessions, we stop delivering features to be able to focus on purely tech subjects, which was a great occasion to migrate the many ports we had to handle and onboard the team on Effect.

As we’re writing this article, we have around 150 full Effect use cases. The rest of the existing use cases will be migrated on the go whenever we need to update them!

We’re already seeing great improvements: for example, implementing rate limiting with just a few lines of code with Effect, whereas we needed a big amount of code to do it with fp-ts. We’re eager to leverage even more the Effect ecosystem now that we have officially migrated to it!

We hope this article motivated you to take the leap from fp-ts to Effect, don’t hesitate to comment if you have any questions or comments!

This article was written by Jeremie Dayan and Laure Retru-Chavastel

Top comments (0)