Porting TCA to TypeScript #2911
technicated
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
Hello everyone! I wanted to let you all know that I am attempting to port the Swift Composable Architecture to TypeScript! This is a project that I'm trying to build in my small tiny amount free time, but I am very passionate about it, and I wanted to share my project here because I've seen some curiosity around this idea in Discussions.
I am trying to port the concepts as 1:1 as possible, as much as TS / JS let me do it. For now, this is what I got.
Any type of feedback is welcome, good or bad that it'll be! Constructive criticism is valuable for growth and improvement. If you have specific questions or aspects, ask them, and I'll try to provide insights or explanations.
Core types
Here we have the main currency types, namely
Reducer
,Effect
,Store
andTestStore
, plus a custom type for representing the State.Reducer
The
Reducer
is implemented as an abstract class with thebody
andreduce
methods implemented in term of each other, like in TCA.A syntax for
ReducerBuilder
-style reducer creation is supported, since the primary use case is sequencing reducers and not using more powerful result builder stuff like if / else / switch.some ReducerOf<...>
is simulated with aSomeReducerOf
union type.The shape of
reduce
is the same as TCA, because the state is always required to be an object (see later) and can thus be modified in place, freeing the return place for theEffect
type. This is in stark contrast with how other state management libraries do it, since usually they require you to return a new state and manage effects as separate classes / types.Effect
The
Effect
type is a class and is implemented using rxjs Observables. This can be desirable or not, but in the future alternatives can be explored (like TCA did when untangling its Effect from Combine). It provides some basic functionalities likemap
,none
andfireAndForget
, and more advanced capabilities like cancellation, however at the moment there is no support for navigation /NavigationPath
.Store
The
Store
is a simple implementation of old TCA versions, there is no concept of aRootStore
or related things, only thesend
method and some recursion using effects. I am working on scoping in a separate branch.To maintain an immutable state while enabling direct mutations within reducers, the Store leverages the immer library. For this reason it is required that the Store's state is an object or, to be more specific, an object extending a base class from the library (more on this later).
TestStore
Porting the
TestStore
was one of the most difficult tasks, because the original one is so beautiful and rich of features! I am not 100% satisfied with the result (maybe not even 80% satisfied!), but at least it seems to work as expected!It uses an
async
interface to wrap all test code within a controlled environment, and inside it you can use thesend
/receive
dance to test your features.There is only the "exhaustive testing" strategy at the moment, or to be more precise it is simply hardcoded, so you must match 100% of everything.
TcaState
This is a library native type, implemented as an abstract class. Its necessity arises from the need to maintain the illusion of in-place state modification, a requirement for both
Store
andReducer
, so the state must be an actual object and not a primitive type since primitives cannot be modified in-place in JS.Moreover, the
Store
requires the state to be compatible withimmer
, and the library requires a symbol to be installed on objects with a prototype in order to correctly copy it, soTcaState
also takes care of this aspect. This does not guarantee that everything is ok, you can still set a bare class as a property of your state, so this is not satisfying and I must still find a solution to this.Custom equitability is also a concern, I have ongoing developments inside the
internal
directory but I am not satisfied with it, more must be done.Reducers
Most of the base Reducers of TCA are ported, with more or less tweaks. For now, they all are implemented as a
<Name>Reducer
class +<Name>
function for their instantiation, to allow for a Swifty syntax without the use of thenew
keyword. However, I'm planning to change this approach because I don't want to impose this convention on the users of the library, and a mixed style in which bothnew
and functions are used in the same ReducerBuilder is worse.DebugReducer
This is implemented like in TCA, allowing for the customisation of the print functionality.
EmptyReducer
This simply works like TCA.
ForEachReducer
This mostly works like TCA, but I am not sure I like how I implemented
IdentifiedArray
(in theidentified-array
directory) and how I used it in this Reducer. Automatic cancellation is supported (but not the navigation one), must be revised but I think it's ok.IfLetReducer
This mostly works like TCA, but does not handle ephemeral state. Automatic cancellation not is supported, because of my concerns about my implementation of
KeyPath
in TS (also in the project, under thekey-path
directory).ReduceReducer
This works like in TCA.
ScopeReducer
This works like in TCA and supports both key-path and case-path scoping. The concept of key path is introduced in the library itself under the
key-path
directory, and for the concept of case path I am using another library of mine, ts-enums, in which I provide an implementation of enums (discriminated unions) in TypeScript.Scheduler
The library introduces a custom scheduler for rxjs, to make time-testing as easy as in TCA.
Dependencies
I have implemented a dependency system similar in shape to that of Swift Dependencies. While the structure is similar, there are some important differences.
DependencyValues
+Storage
,Dependency(Test)Key
andwithDependencies
work almost the same, and dependencies are registered in the same way of Swift Dependencies (create key + getter / setter pair).There are two fundamental differences between Swift Dependencies and my implementation of dependencies:
@Dependency
property wrapper: the library uses adependency
free function for resolving dependencies. JavaScript has no concept of task, task local values and other multi-threaded related concepts since it is implemented as a single threaded language, so a lot of aspects of Swift Dependencies that deal with it are not necessary / applicable. Moreover, even if decorators are a thing, using them to model dependencies demands more effort and manual type hinting from developers, and I am not sure of the advantages, except from what's pointed out in the next bullet point.@Dependency
reading the exact same value in different contexts can give you a different dependency, while in my case the dependency is statically resolved at the moment of thedependency
function invocation and never changes.These differences affect how the library handles dependency resolution in async contexts, such as in Effects, but this should not have an impact on how you actually use them, minus an exception, which I'll illustrare in a bit. In the case of the library, Effects will capture
this
in their closures at the moment they are created, so they will always use the dependencies attached tothis
at the time of the capture, so if the Reducer was created with the right dependency everything will work as intended.However (and here's the catch) this means that
Reducer.withDependencies()
cannot exist in this library, since dependencies cannot be modified after the Reducer creation. Consequently, any instance where you would typically use.withDependencies
in TCA needs to be substituted with a call to thewithDependencies
free function wrapping the Reducer creation.In the end
If you are arrived here, thank you very much for you time and curiosity!
As stated before, any type of feedback is welcome, from good to bad! I'm open to hear everything!
Cheers, Andrea
Beta Was this translation helpful? Give feedback.
All reactions