Tweag
Technical groups
Dropdown arrow
Open source
Careers
Research
Blog
Contact
Consulting services
Technical groups
Dropdown arrow
Open source
Careers
Research
Blog
Contact
Consulting services

Exploring Effect in TypeScript: Simplifying Async and Error Handling

7 November 2024 — by Douglas Massolari

Effect is a powerful library for TypeScript developers that brings functional programming techniques into managing effects and errors. It aims to be a comprehensive utility library for TypeScript, offering a range of tools that could potentially replace specialized libraries like Lodash, Zod, Immer, or RxJS.

In this blog post, we will introduce you to Effect by creating a simple weather widget app. This app will allow users to search for weather information by city name, making it a good example as it involves API data fetching, user input handling, and error management. We will implement this project in both vanilla TypeScript and using Effect to demonstrate the advantages Effect brings in terms of code readability and maintainability.

What is Effect?

Effect promises to improve TypeScript code by providing a set of modules and functions that are composable with maximum type-safety. The term “effect” refers to an effect system, which provides a declarative approach to handling side effects. Side effects are operations that have observable consequences in the real world, like logging, network requests, database operations, etc. The library revolves around the Effect<Success, Error, Requirements> type, which can be used to represent an immutable value that lazily describes a workflow or job. Effects are not functions by themselves, they are descriptions of what should be done. They can be composed with other effects, and they can be interpreted by the Effect runtime system. Before we dive into the project we will build, let’s look at some basic concepts of Effect.

Creating effects

We can create an effect based on a value using the Effect.succeed and Effect.fail functions:

const success: Effect.Effect<number, never, never> = Effect.succeed(42)

const fail: Effect.Effect<never, Error, never> = Effect.fail(
  new Error("Something went wrong")
)
  • An effect with never as the Error means it never fails
  • An effect with never as the Success means it never produces a successful value.
  • An effect with never as the Requirements means it doesn’t require any context to run.

With the functions above, we can create effects like this:

const divide = (a: number, b: number): Effect.Effect<number, Error, never> =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b)

To create an effect based on a function, we can use the Effect.sync and Effect.promise for synchronous and asynchronous functions that can’t fail, respectively, and Effect.try and Effect.tryPromise for synchronous and asynchronous functions that can fail.

// Synchronous function that can't fail
const log = (message: string): Effect.Effect<void, never, never> =>
  Effect.sync(() => console.log(message))

// Asynchronous function that can't fail
const delay = (message: string): Effect.Effect<string, never, never> =>
  Effect.promise<string>(
    () =>
      new Promise(resolve => {
        setTimeout(() => {
          resolve(message)
        }, 2000)
      })
  )

// Synchronous function that can fail
const parse = (input: string): Effect.Effect<any, Error, never> =>
  Effect.try({
    // JSON.parse may throw for bad input
    try: () => JSON.parse(input),
    // remap the error
    catch: _unknown => new Error(`something went wrong while parsing the JSON`),
  })

// Asynchronous function that can fail
const getTodo = (id: number): Effect.Effect<Response, Error, never> =>
  Effect.tryPromise({
    // fetch can throw for network errors
    try: () => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`),
    // remap the error
    catch: unknown => new Error(`something went wrong ${unknown}`),
  })

For more details about creating effects you can check the Effect documentation.

Running effects

In order to run an effect, we need to use the appropriate function depending on the effect type. In our application we’ll use the Effect.runPromise function, which is used for effects that are asynchronous and can’t fail:

Effect.runPromise(delay("Hello, World!")).then(console.log)
// -> Hello, World! (after 2 seconds)

You can read about other ways to run effects, and what happens when you don’t use the correct function, in the “Running Effects” page of the Effect documentation.

Pipe

When writing a program using Effect, we usually need to run a sequence of operations, and we can use the pipe function to compose them:

const double = (n: number) => n * 2

const divide =
  (b: number) =>
  (a: number): Effect.Effect<number, Error> =>
    b === 0
      ? Effect.fail(new Error("Cannot divide by zero"))
      : Effect.succeed(a / b)

const increment = (n: number) => Effect.succeed(n + 1)

const result = pipe(
  42,
  // Here we have an Effect.Effect<number, Error> with the value 21
  divide(2),
  // To run a function over the value changing the effect's value, we use Effect.map
  Effect.map(double),
  // To run a function over the value without changing the effect's value, we use Effect.tap
  Effect.tap(n => console.log(`The double is ${n}`)),
  // To run a function that returns a new effect, we use Effect.andThen
  Effect.andThen(increment),
  Effect.tap(n => console.log(`The incremented value is ${n}`))
)

Effect.runSync(result)
// -> The double is 42
// -> The incremented value is 43

If you want to know more about the pipe function, you can check this page on the Effect documentation.

The project

Now that we have a basic understanding of Effect, we can start the project! We will build a simple weather app in which the user types the name of a city, selects the desired one from a list of suggestions, and then the app shows the current weather in that city.
The project will have three main components: the input field, the list of suggestions, and the weather information.

We will use the Open-Meteo API to get the weather information as it doesn’t require an API key.

Setup

We begin by creating a new TypeScript project:

mkdir weather-app
cd weather-app
npm init -y

Next, we install the dependencies. We will use Parcel to bundle the project as it works without any configuration:

npm install --save-dev parcel

Now we create the project structure:

mkdir src
touch src/index.html
touch src/styles.scss
touch src/index.ts

The index.html file contains a main element with sections: one with a text input for city input and another for displaying weather information.

You can check the HTML and SCSS code in the GitHub repository.

In order to run the project, we need to add the following keys to the package.json file:

{
  "source": "./src/index.html",
  "scripts": {
    "dev": "parcel",
    "build": "parcel build"
  }
}

Now we can run the project:

npm run dev

Server running at http://localhost:1234
✨ Built in 8ms

By accessing the URL, you should see the application, but it won’t work yet.

Figure 1. Application's initial state
Figure 1. Application's initial state

Let’s write the TypeScript code!

Without Effect

All the following code examples should be placed in the src/index.ts file.

First, we query the elements from the DOM:

// The field input
const cityElement = document.querySelector<HTMLInputElement>("#city")
// The list of suggestions
const citiesElement = document.querySelector<HTMLUListElement>("#cities")
// The weather information
const weatherElement = document.querySelector<HTMLDivElement>("#weather")

Next, we’ll define the types for the data we’ll fetch from the API.
To validate the data, we’ll use a library called Zod. Zod is a TypeScript-first schema declaration and validation library.

npm install zod

First, we define the schema by using z.object and, for each property, we use z.string, z.number and other functions to define its type:

import { z } from "zod"

// ...

const CityResponse = z.object({
  name: z.string(),
  country_code: z.string().length(2),
  latitude: z.number(),
  longitude: z.number(),
})

const GeocodingResponse = z.object({
  results: z.array(CityResponse),
})

With the schema defined, we can use the z.infer utility type to infer the type of the data based on the schema:

type CityResponse = z.infer<typeof CityResponse>

type GeocodingResponse = z.infer<typeof GeocodingResponse>

Now, we create the function to fetch the cities from the Open-Meteo API. It fetches the cities that match the given name and returns a list of suggestions. In order to validate the API response, we use the safeParse method that our GeocodingResponse Zod schema provides. This method returns an object with two key properties:

  1. success: A boolean indicating if the parsing succeeded.
  2. data: The parsed data if successful, matching our defined schema.
const getCity = async (city: string): Promise<CityResponse[]> => {
  try {
    const response = await fetch(
      `https://geocoding-api.open-meteo.com/v1/search?name=${city}&count=10&language=en&format=json`
    )

    // Convert the response to JSON
    const geocoding = await response.json()

    // Parse the response using the GeocodingResponse schema
    const parsedGeocoding = GeocodingResponse.safeParse(geocoding)

    if (!parsedGeocoding.success) {
      return []
    }

    return parsedGeocoding.data.results
  } catch (error) {
    console.error("Error:", error)
    return []
  }
}

To make the input field work, we need to attach an event listener to it to call the getCity function:

const getCities = async function (input: HTMLInputElement) {
  const { value } = input

  // Check if the HTML element exists
  if (citiesElement) {
    // Clear the list of suggestions
    citiesElement.innerHTML = ""
  }

  // Check if the input is empty
  if (!value) {
    return
  }

  // Fetch the cities
  const results = await getCity(value)

  renderCitySuggestions(results)
}

cityElement?.addEventListener("input", function (_event) {
  getCities(this)
})

Next, we create the renderCitySuggestions function to render the list of suggestions or display an error message if there are no suggestions:

const renderCitySuggestions = (cities: CityResponse[]) => {
  // If there are cities, populate the suggestions
  if (cities.length > 0) {
    populateSuggestions(cities)
    return
  }

  // Otherwise, show a message that the city was not found
  if (weatherElement) {
    const search = cityElement?.value || "searched"
    weatherElement.innerHTML = `<p>City ${search} not found</p>`
  }
}

The populateSuggestions function is very simple - it creates a list item for each city:

const populateSuggestions = (results: CityResponse[]) =>
  results.forEach(city => {
    const li = document.createElement("li")
    li.innerText = `${city.name} - ${city.country_code}`
    citiesElement?.appendChild(li)
  })

Now if we type a city name in the input field, we should see the list of suggestions:

Figure 2. City suggestions
Figure 2. City suggestions

Great!

The next step is to implement the selectCity function that fetches the weather information of a city and displays it:

const selectCity = async (result: CityResponse) => {
  // If the HTML element doesn't exist, return
  if (!weatherElement) {
    return
  }

  try {
    const data = await getWeather(result)

    if (data.tag === "error") {
      throw data.value
    }

    const {
      temperature_2m,
      apparent_temperature,
      relative_humidity_2m,
      precipitation,
    } = data.value.current

    weatherElement.innerHTML = `
 <h2>${result.name}</h2>
 <p>Temperature: ${temperature_2m}°C</p>
 <p>Feels like: ${apparent_temperature}°C</p>
 <p>Humidity: ${relative_humidity_2m}%</p>
 <p>Precipitation: ${precipitation}mm</p>
 `
  } catch (error) {
    weatherElement.innerHTML = `<p>An error occurred while fetching the weather: ${error}</p>`
  }
}

Then we call it in the populateSuggestions function:

const populateSuggestions = (results: CityResponse[]) =>
  results.forEach(city => {
    // ...
    li.addEventListener("click", () => selectCity(city))
    citiesElement?.appendChild(li)
  })

The last piece of the puzzle is the getWeather function. Once again, we’ll use Zod to create the schema and the type for the weather information.

type WeatherResult =
  | { tag: "ok"; value: WeatherResponse }
  | { tag: "error"; value: unknown }

const WeatherResponse = z.object({
  current_units: z.object({
    temperature_2m: z.string(),
    relative_humidity_2m: z.string(),
    apparent_temperature: z.string(),
    precipitation: z.string(),
  }),
  current: z.object({
    temperature_2m: z.number(),
    relative_humidity_2m: z.number(),
    apparent_temperature: z.number(),
    precipitation: z.number(),
  }),
})

type WeatherResponse = z.infer<typeof WeatherResponse>

const getWeather = async (result: CityResponse): Promise<WeatherResult> => {
  try {
    const response = await fetch(
      `https://api.open-meteo.com/v1/forecast?latitude=${result.latitude}&longitude=${result.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation&timezone=auto&forecast_days=1`
    )

    // Convert the response to JSON
    const weather = await response.json()

    // Parse the response using the WeatherResponse schema
    const parsedWeather = WeatherResponse.safeParse(weather)

    if (!parsedWeather.success) {
      return { tag: "error", value: parsedWeather.error }
    }

    return { tag: "ok", value: parsedWeather.data }
  } catch (error) {
    return { tag: "error", value: error }
  }
}

We have a type WeatherResult for error handling; it can be ok or error. The getWeather function fetches the weather information based on the latitude and longitude of a city and returns the result. We are passing some parameters to the API to get the current temperature, humidity, apparent temperature, and precipitation. If you want to know more about these parameters, you can check the API documentation.

One last thing we need to do is to use a debounce function to avoid making too many requests to the API while the user is typing. To do that, we’ll install Lodash which provides many useful functions for everyday programming.

npm install lodash
npm install --save-dev @types/lodash

We’ll wrap the getCities function with the debounce function:

import { debounce } from "lodash"

// ...

const getCities = debounce(async function (input: HTMLInputElement) {
  // The same code as before
}, 500)

This way, the getCities function will be called only after the user stops typing for 500 milliseconds.

Our small weather app is now complete: when we type a city name in the input field, a list of suggestions is displayed, and when we click on one of them, we can see the weather information for that city.

Figure 3. Weather information
Figure 3. Weather information

While our current code works and handles errors well, let’s explore how using Effect can potentially improve its robustness and simplicity.

With Effect

To get started with Effect, we need to install it:

npm install effect

We will start by refactoring the functions in the order we implemented them in the previous section.

First, we refactor the querySelector calls. We’ll use the Option type from Effect: it represents a value that may or may not exist. If the value exists, it’s a Some, if it doesn’t, it’s a None.

import { Option } from "effect"

// The field input
const cityElement = Option.fromNullable(
  document.querySelector<HTMLInputElement>("#city")
)
// The list of suggestions
const citiesElement = Option.fromNullable(
  document.querySelector<HTMLUListElement>("#cities")
)
// The weather information
const weatherElement = Option.fromNullable(
  document.querySelector<HTMLDivElement>("#weather")
)

Using the Option type, we can chain operations without worrying about null or undefined values. This approach simplifies our code by eliminating the need for explicit null checks. We can use functions like Option.map and Option.andThen to handle the transformations and checks in a more elegant way. To know more about the Option type, take a look at the page about it in the documentation.

Now, let’s move to the getCity function. We’ll use the Schema.Struct to define the types of the CityResponse and GeocodingResponse objects. Those schemas will be used to validate the response from the API. This is the same thing we did before with Zod, but this time we don’t have to install any library. Instead, we can just use the Schema module that Effect provides.

import { /* ... */, Effect, Scope, pipe } from "effect";
import { Schema } from "@effect/schema"
import {
  FetchHttpClient,
  HttpClient,
  HttpClientResponse,
  HttpClientError
} from "@effect/platform";

// ...

const CityResponse = Schema.Struct({
  name: Schema.String,
  country_code: pipe(Schema.String, Schema.length(2)),
  latitude: Schema.Number,
  longitude: Schema.Number,
})

type CityResponse = Schema.Schema.Type<typeof CityResponse>

const GeocodingResponse = Schema.Struct({
  results: Schema.Array(CityResponse),
})

type GeocodingResponse = Schema.Schema.Type<typeof GeocodingResponse>

const getRequest = (url: string): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError, Scope.Scope> =>
  pipe(
    HttpClient.HttpClient,
    // Using `Effect.andThen` to get the client from the `HttpClient.HttpClient` tag and then make the request
    Effect.andThen(client => client.get(url)),
    // We don't need to send the tracing headers to the API to avoid CORS errors
    HttpClient.withTracerPropagation(false),
    // Providing the HTTP client to the effect
    Effect.provide(FetchHttpClient.layer)
  )

const getCity = (city: string): Effect.Effect<readonly CityResponse[], never, never> =>
  pipe(
    getRequest(
      `https://geocoding-api.open-meteo.com/v1/search?name=${city}&count=10&language=en&format=json`
    ),
    // Validating the response using the `GeocodingResponse` schema
    Effect.andThen(HttpClientResponse.schemaBodyJson(GeocodingResponse)),
    // Providing a default value in case of failure
    Effect.orElseSucceed<GeocodingResponse>(() => ({ results: [] })),
    // Extracting the `results` array from the `GeocodingResponse` object
    Effect.map(geocoding => geocoding.results),
    // Providing a scope to the effect
    Effect.scoped
  )

Here we already have some interesting things happening!

The getRequest function sets up the HTTP client. While we could use the built-in fetch API as our HTTP client, Effect provides a solution called HttpClient in the @effect/platform package. It’s important to note that this package is currently in beta, as mentioned in the official documentation. Despite its beta status, we’ll be using it to explore more of Effect’s capabilities and showcase how it integrates with the broader Effect ecosystem. This choice allows us to demonstrate Effect’s approach to HTTP requests and error handling in a more idiomatic way. HttpClient.HttpClient is something called a “tag” that we can use to get the HTTP client from the context. To do that, we use the Effect.andThen function.
After that, we’re setting withTracerPropagation to false to avoid sending the tracing headers to the API and getting a CORS error.

Since we’re using the HttpClient service, it’s a requirement to our effect (remember the Effect<Success, Error, Requirements> type?) and we need to provide this requirement in order to run the effect.
With the Effect.provide function we can add a layer to the effect that provides the HttpClient service. For more information about the Effect.provide function and how it works, take a look at the runtime page on the Effect documentation.

In the getCity function, we call the getRequest function to get the response from the API. Then we validate the response using the HttpClientResponse.schemaBodyJson function, which validates the response body using the GeocodingResponse schema.
In the last line of the function, we use the Effect.scoped function to provide a scope to the effect, this is a requirement for the HttpClient service that we’re using in the getRequest function. The scope ensures that if the program is interrupted, any request will be aborted, preventing memory leaks. getCity returns a Effect.Effect<CityResponse[], never, never>: the two never means it never fails (we’re providing a default value in case of failure), and it doesn’t require any context to run.

Next, we refactor the getCities function:

import { /* ... */, Effect, Option, pipe } from "effect";

// ...

const getCities = (search: string): Effect.Effect<Option.Option<void>, never, never> => {
  Option.map(citiesElement, citiesEl => (citiesEl.innerHTML = ""))

  return pipe(
    getCity(search),
    Effect.map(renderCitySuggestions),
    // Check if the input is empty
    Effect.when(() => Boolean(search))
  )
}

We’re using the Option.map function to access the actual citiesElement and clear the list of suggestions. After that, it’s pretty straightforward: we call the getCity function with the search term, then we map the renderCitySuggestions function over the successful value, and finally, we apply a condition that makes the effect run only if the search term is not empty.

Here is how we add the event listener to the input field:

import { /* ... */, Effect, Option, pipe, Stream, Chunk, StreamEmit } from "effect";

// ...

Option.map(cityElement, cityEl => {
  const stream = Stream.async(
    (emit: StreamEmit.Emit<never, never, string, void>) =>
      cityEl.addEventListener("input", function (_event) {
        emit(Effect.succeed(Chunk.of(this.value)))
      })
  )

  pipe(
    stream,
    Stream.debounce(500),
    Stream.runForEach(getCities),
    Effect.runPromise
  )
})

Actually, we’re doing more than just adding an event listener. The debounce function that we had to import from Lodash before is now part of Effect as the Stream.debounce function. In order to use this function, we need to create a Stream.
A Stream has the type Stream<A, E, R> and it’s a program description that, when executed, can emit zero or more values of type A, handle errors of type E, and operates within a context of type R. There are a couple of ways to create a Stream, which are detailed in the page about streams in the documentation. In this case, we’re using the Stream.async function as it receives a callback that emits values to the stream.

After creating the Stream and assigning it to the stream variable, we use a pipe to build a pipeline where we debounce the stream by 500 milliseconds, run the getCities function whenever the stream gets a value (that is, when we emit a value), and finally run the effect with Effect.runPromise.

Let’s move on to the renderCitySuggestions function:

import { /* ... */, Array, Option, pipe } from "effect";

// ...

const renderCitySuggestions = (cities: readonly CityResponse[]): void | Option.Option<void> =>
  // If there are multiple cities, populate the suggestions
  // Otherwise, show a message that the city was not found
  pipe(
    cities,
    Array.match({
      onNonEmpty: populateSuggestions,
      onEmpty: () => {
        const search = Option.match(cityElement, {
          onSome: (cityEl) => cityEl.value,
          onNone: () => "searched",
        });

        Option.map(
          weatherElement,
          (weatherEl) =>
            (weatherEl.innerHTML = `<p>City ${search} not found</p>`),
        );
      },
    }),
  );

Instead of manually checking the length of the cities array, we’re using the Array.match function to handle that. If the array is empty, it calls the callback defined in the onEmpty property, and if the array is not empty, it calls the callback defined in the onNonEmpty property.

The populateSuggestions function remains almost the same. The only change is that we now wrap the forEach operation in an Option.map to safely handle the optional cities element. This ensures we only attempt to populate suggestions when the element exists.

The selectCity function is simpler now:

import { /* ... */, Option, pipe } from "effect";

// ...

const selectCity = (result: CityResponse): Option.Option<Promise<string>> =>
  Option.map(weatherElement, weatherEl =>
    pipe(
      result,
      getWeather,
      Effect.match({
        onFailure: error =>
          (weatherEl.innerHTML = `<p>An error occurred while fetching the weather: ${error}</p>`),
        onSuccess: (weatherData: WeatherResponse) =>
          (weatherEl.innerHTML = `
<h2>${result.name}</h2>
<p>Temperature: ${weatherData.current.temperature_2m}°C</p>
<p>Feels like: ${weatherData.current.apparent_temperature}°C</p>
<p>Humidity: ${weatherData.current.relative_humidity_2m}%</p>
<p>Precipitation: ${weatherData.current.precipitation}mm</p>
`),
      }),
      Effect.runPromise
    )
  )

There is no checking for the data.tag any more, we’re using the Effect.match function to handle both cases, success and failure, and we don’t throw anything anymore.

Finally, the getWeather function:

import { /* ... */, Effect, pipe } from "effect";
import { Schema, ParseResult } from "@effect/schema";
import { /* ... */, HttpClientResponse, HttpClientError } from "@effect/platform";

// ...

const WeatherResponse = Schema.Struct({
  current_units: Schema.Struct({
    temperature_2m: Schema.String,
    relative_humidity_2m: Schema.String,
    apparent_temperature: Schema.String,
    precipitation: Schema.String,
  }),
  current: Schema.Struct({
    temperature_2m: Schema.Number,
    relative_humidity_2m: Schema.Number,
    apparent_temperature: Schema.Number,
    precipitation: Schema.Number,
  }),
})

type WeatherResponse = Schema.Schema.Type<typeof WeatherResponse>

const getWeather = (
  result: CityResponse,
): Effect.Effect<WeatherResponse, HttpClientError.HttpClientError | ParseResult.ParseError, never> =>
  pipe(
    getRequest(
      `https://api.open-meteo.com/v1/forecast?latitude=${result.latitude}&longitude=${result.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation&timezone=auto&forecast_days=1`
    ),
    Effect.andThen(HttpClientResponse.schemaBodyJson(WeatherResponse)),
    Effect.scoped
  )

We’re again using the Schema.Struct to define the WeatherResponse type. However, we don’t need to have a WeatherResult anymore as the Effect type already handles the success and failure cases.

After this refactoring, the app works the same way it did before, but now we have the confidence that our code is more robust and type-safe. Let’s see the benefits of Effect when comparing to the code without it.

Conclusion

Now that we have the two versions of the application, we can analyze them and highlight the pros and cons of using Effect:

Pros

  • Type-safety: Effect provides a way to handle errors and requirements in a type-safe way and using it increases the overall type safety of our app.
  • Error handling: The Effect type has built-in error handling, making the code more robust.
  • Validation: We don’t need to use a library like Zod to validate the response - we can use the Schema module to validate the response.
  • Utility functions: We don’t need to use a library like Lodash to use utility functions. Instead, we can use the Array, Option, Stream, and other modules.
  • Declarative style: Writing code with Effect means we’re using a more declarative approach: we’re describing “what” we want our program to do, rather than “how” we want it to do it.

Cons

  • Complexity: The code is more complex than the one without Effect; it may be hard to understand for people who are not familiar with the library.
  • Learning curve: You need to learn how to use the library - it’s not as simple as writing plain TypeScript code.
  • Documentation: The documentation is good, but could be better. Some parts are not clear.

While the code written with Effect may initially appear more complex to those unfamiliar with the library, its benefits far outweigh the initial learning curve. Effect offers powerful tools for maximum type-safety, error handling, asynchronous operations, streams and more, all within a single library that is incrementally adoptable. In our project, we used two separate libraries (Zod and Lodash) to achieve what Effect accomplishes on its own.

While plain TypeScript may be adequate for small projects, we believe Effect can truly shine in larger, more complex applications. Its robust handling of side-effects and comprehensive error management have the potential to make it a game changer for taming complexity and maintaining code quality at scale.

About the author

Douglas Massolari

Douglas is a Software Engineer who values functional programming and clean code. He constantly improves his skills and stays current in the field. Douglas aims to provide top user experiences for clients and users.

If you enjoyed this article, you might be interested in joining the Tweag team.

This article is licensed under a Creative Commons Attribution 4.0 International license.

Company

AboutOpen SourceCareersContact Us

Connect with us

© 2024 Modus Create, LLC

Privacy PolicySitemap