I work primarily in Typescript and after reading through the docs a few times I'm still a bit confused about how to use generic types in Nim, especially for data types with multiple generic type parameters. I'm a big fan of and heavy user of effect-ts https://effect.website/ and thought it would be a fun way to learn nim to try building a toy implementation of the Effect type.
The gist of the Effect type is Effect<A,E,R>, 3 generic type params where A is the result type, E is the possible error type, and R is the requirements/context. This type can model any computation and can be manipulated/combined with a huge library of combinators. Effect was inspired by Zio in scala. How might I model the types for something like the following TS in Nim?
class MyError1 {
readonly _tag = "MyError1"
}
class MyError2 {
readonly _tag = "MyError2"
}
// provides an interface for the Random service to impelement along with a value (the class) that can be used to look up the service in context
class Random extends Context.Tag("MyRandomService")<
Random,
{ next: Effect.Effect<number, MyError1> }
>() {}
//type of program ends up being Effect<void, never, Random>
//so it returns nothing, can't error, and requires an instance of the Random service
const program = pipe(
Effect.flatMap(Random, r => r.next),
//Type is Effect<number, MyError1, Random>
Effect.flatMap(num => num < 0.5 ? Effect.succeed("ok!") : Effect.fail(new MyError2()))
//Type is now Effect<string, MyError1 | MyError2, Random> because we flatMapped the number to a string in success case and added MyError2 in failure case
Effect.catchTags({
MyError1: (err) => Effect.succeed(`failure with MyError1`),
MyError2: (err) => Effect.succeed(`failure with MyError2`)
})
// Type is now Effect<string, never, Random> because we transformed both possible error cases into success cases of string
Effect.flatMap(res => Effect.logInfo(`success? ${res}`))
//final type is now Effect<void, never, Random> because logInfo returns void
)
//construct a service instance
const MyRandomInstance: Random = { next: Effect.sync(() => Math.random() }
//finalProgram is Effect<void, never, never> because we provided the requirement Random
const finalProgram = pipe(
program,
Effect.provideService(Random, MyRandomInstance)
)
//result is Exit<void, never> (basically Exit is an Either type)
const result = await Effect.runPromiseExit(finalProgram)
I think nim's type system should be capable of this sort of thing but after reading the docs several times I'm unsure about how an Effect<A,E,R> type would be modeled. Any resources or input to help me grok more complex types in nim would be appreciated!
To simplify the question I think I'm most curious about how one would model a type with 3 generic parameters and then write combinators that manipulate those generics. Like how flatMap is
<A,E,R>(self: Effect<A,E,R>, fn: <B,E1,R1>(v: A) => Effect<B, E1, R1>) => Effect<B, E | E1, R | R1>
How would you model the Effect<A,E,R> type and write a flatMap that works like the TS example above in Nim?
I doubt it can be done as Nim's | operator for types does not do what TS's does, so "Inferring" MyError1 | MyError2 is downright impossible.
On the bright side though, you can simply use Nim's builtin effect system (which is not an effect system but nobody noticed for 10 years, so it's good enough :P ).
Hello imagio,
I don't use Typescript enough to understand the construct you research. But I think the kind of idioms you search could be expressed differently in Nim. Your Effect object, if i am not wrong is returned from your function. You can easily return an Option (std/options) to have an optional value. You can also return an inherited object to have more or less fields depending of your exact object, and your could simply return an object with multiple fields.
Having an object with more than two generics type in a compiled language seems too much IMHO.
type ErrorCode = enum
HttpError, ValueError, etc.
type MyEffect[T] = object
isError: bool
errorCode: ErrorCode
value: T
That was just a minimal example, the actual stuff in https://effect.website/ and https://zio.dev/ are much more advanced. Structured concurrency, parallelism, error management, retries, dependency injection, request batching, caching, tracing, scheduling, streaming, STM (software transactional memory), schemas/parsing, fully typed rpc, an insane amount of composability, and tons more are all built from the primitive concept of SomeType[A,E,R]. It's a really powerful model.
So there's currently no way in nim to express the following function?
proc foo[T,U](t: T, u: U): T or U =
if rand(1..10) > 5:
t
else:
u
If this is not possible could you help me understand why?
It is not possible, because Nim is a statically strongly typed language. It is not possible to determine exactly the type of your return value.
However, you could do something like this:
import std/random
randomize()
type Parent = ref object of RootRef
type ChildA = ref object of Parent
type ChildB = ref object of Parent
var a = ChildA()
var b = ChildB()
proc foo(t, u: Parent): Parent =
if rand(1..10) > 5:
t
else:
u
var res = foo(a, b)
if res of ChildA:
echo "ChildA"
else:
echo "ChildB"
Nim does have Typeclasses and sort-of-ADTs via object variants it's just not possible right now AFAICT to have the compiler infer new anonymous ones. Or I guess to put it another way type TypeClass = int | string works but the compiler won't create a new typeclass like that "on the fly" to represent the return type of a proc that could return int | string. Does that sound right?
As far as I can tell there's no reason why the type system couldn't do that since it's possible to do it with a manual definition. I think it would probably be possible to have anonymous/inferred union types because I think you could fill in every possible concrete variant by analyzing callers of the a proc. I'd guess it would be fairly complex however and I'm no expert on compilers and type systems.
Does that sound right?
Yes, precisely.
As for "SomeType[A,E,R] is a really powerful model". Sure but it still has to prove itself in the real world. What usually happens is that when your function types are very precise is that everything of the function's implementation is exposed and so the code can hardly evolve without breaking the function callers and you get viral ripple effects throughout an entire program.
To complete with sum types:
type ErrorCode = enum
HttpError, ValueError
type MyEffect[T] = object
case isError: bool
of true:
case errorCode: ErrorCode:
of HttpError:
pageContent: string
else:
discard
else:
value: T
var effect1 = MyEffect[void](isError: true, errorCode: HttpError, pageContent: "<html>This is the end</html>")
var effect2 = MyEffect[int](isError: false, value: 42)
They could also allow you to return an object with multiple facets. But still, all variables need to have concret type, so has the documentation said (https://nim-lang.org/docs/manual.html#generics-type-classes):
type TypeClass = int | string
var foo: TypeClass = 2 # foo's type is resolved to an int here
foo = "this will fail" # error here, because foo is an int
Indeed, in Nim, you could do a lot and still be concise with some simple structures. I think concepts are better understood, used and won't be subject to big refactors if they express core elements. And generally those objects don't need a lot of inheritance or complex generics.
Creating a type (like an object) in Nim is cheap both in typing, in memory and in cpu. So don't be afraid to create as much as you need and be very specific about them. Nim don't encourage OOP principles where you have complex classes. In Nim, objects are just data, they have no special powers. I think that's quite the contrary to OOP languages where classes are so cumbersome we tend to forget to keep them simple.
Sure but it still has to prove itself in the real world. What usually happens is that when your function types are very precise is that everything of the function's implementation is exposed and so the code can hardly evolve without breaking the function callers and you get viral ripple effects throughout an entire program.
I'm not sure I completely understand. The concept (implemented by zio and effect) is well proven in production. If you change the requirements/arguments, possible errors, or success value of some computation don't we want a ripple effect where the compiler shows us all the places our change could break things? That's where the value is in static typing isn't it?
That's why the zio/effect pattern works well on jvm/js. It provides a way fully describe the context and errors of computations at the type level so you can change/refactor/compose with confidence that you haven't broken anything.
Model important things, leave others underspecified so that you can add logging for example without having to rewrite your program
That's one of the reasons I think the zio/effect is really good. It lets you specify your requirements and errors completely at the type level but you don't have to rewrite your program to change things like adding logging.
If you want to add logging (or anything else you might need) deep in your program you provide it as a service via context. Services are usually only provided once at the top of your program and are then usable wherever required. You can switch out the implementation at will for testing or requirements of different environments. It's a "have your cake and eat it too" situation where you get full type safety of errors and requirements but aren't forced to rewrite/restructure things in any major way as the program changes.