Timeline is a lightweight, functional reactive programming (FRP) library that provides elegant state management across multiple programming languages. Originally implemented in F#, this repository now includes ports to various languages while maintaining the same core principles and API.
Timeline offers a simple yet powerful abstraction for managing and propagating state changes throughout your application. At its core, Timeline implements a reactive pattern where values change over time and these changes automatically trigger registered functions, creating a clean, declarative approach to state management.
Key features:
- Minimal dependency footprint
- Functional programming inspired design
- Composable operations (map/bind/and/or)
- Consistent API across different language implementations
- Easy integration with existing codebases
Whether you're building user interfaces, managing application state, handling asynchronous events, or coordinating complex data flows, Timeline provides a unified approach that works seamlessly across language boundaries.
// Create a new timeline with initial value
const counterTimeline = Timeline(0);
// Register a listener to react to changes
counterTimeline
.map(count =>
console.log(`Counter changed to: ${count}`)
);
// logs: "Counter changed to: 0"
// Update the timeline value
counterTimeline.next(1); // logs: "Counter changed to: 1"
counterTimeline.next(2); // logs: "Counter changed to: 2"
// Initialize Timeline with Null
// This creates a new Timeline that can hold either a string or null
const timeline = Timeline<string | null>(null);
// Map the Timeline to register a callback function
// This function will be called whenever the Timeline value changes
// The behavior is similar to a Promise's .then() method
timeline
.map(value => {
if (isNullT(value)) {
// Skip processing when value is null
// This allows for conditional handling based on value state
} else {
// Process and display non-null values
log(value);
}
});
// Update the Timeline with new values
// Each next() call triggers the map function above
timeline.next("Hello"); // Logs: "Hello"
timeline.next("World!"); // Logs: "World!"
timeline.next("TypeScript"); // Logs: "TypeScript"
timeline.next(null); // No logging occurs (null value)
let asyncOr1 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// Or binary operator
let (|||) = TL.Or
let timelineABC =
timelineA ||| timelineB ||| timelineC
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A" // "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C"
let asyncOr2 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// Any of these
let timelineABC =
TL.Any [timelineA; timelineB; timelineC]
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A" // "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C"
let asyncAnd1 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// And binary operator
let (&&&) = TL.And
let timelineABC =
timelineA &&& timelineB &&& timelineC
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C" // { result = ["A"; "B"; "C"] }
let asyncAnd2 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// All of these
let timelineABC =
TL.All [timelineA; timelineB; timelineC]
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C" // { result = ["A"; "B"; "C"] }
// Timeline bind sequence
const timeline0 = Timeline<string | null>(null);
const timeline1 = Timeline<string | null>(null);
const timeline2 = Timeline<string | null>(null);
const timeline3 = Timeline<string | null>(null);
// Chain of bindings with setTimeout
timeline0
.bind(value => {
if (isNullT(value)) {
// Do nothing if value is null
} else {
setTimeout(() => {
const msg = "Hello";
log(msg);
timeline1.next(msg);
}, 1000);
}
return timeline1;
}) // Return timeline1 directy to chain the next bind
.bind(value => {
if (isNullT(value)) {
// Do nothing if value is null
} else {
setTimeout(() => {
const msg = value + " World!";
log(msg);
timeline2.next(msg);
}, 2000);
}
return timeline2;
}) // Return timeline2 directy to chain the next bind
.bind(value => {
if (isNullT(value)) {
// Do nothing if value is null
} else {
setTimeout(() => {
const msg = value + " Sequence ends.";
log(msg);
timeline3.next(msg);
}, 1000);
}
return timeline3;
}); // Return timeline3 directy to chain the next bind
// Start the sequence to trigger the first bind
timeline0.next("Start!");
Given the critical significance of Functional Programming in modern software development, I have dedicated a separate article to exploring its key concepts and benefits.
In Functional Programming, everything is an expression or operation (💡 What is Functional Programming?). Accordingly, Timeline provides binary operations for the reactive state management .
This binary operation corresponds to an operation in spreadsheet apps.
-
$TimelineA \quad = \quad$ A1 -
$TimelineB \quad = \quad$ B1 -
$Function \quad = \quad$ fx
'a -> Timeline<'a>
let counter = Timeline 0
Consider the Timeline
as a specific container for a value, similar to a Cell in spreadsheet apps.
('a -> 'b) -> (Timeline<'a> -> Timeline<'b>)
('a -> Timeline<'b>) -> (Timeline<'a> -> Timeline<'b>)
When the binary operator: TL.map
,
let double = fun a -> a * 2
let timelineA = Timeline 1
let timelineB =
timelineA |> TL.map double
log (timelineB |> TL.last)
// 2
This code for the binary operation simply corresponds to the basic usage of spreadsheet apps
This is the identical structure of:
let double = a => a * 2;
let listA = [1];
let listB =
listA.map(double);
console.log(listB);
// [2]
We could recognize the array [2]
is identical to the Cell and Value 2
of a spreadsheet; however, the spreadsheet and Timeline maintain a double
relationship as values change over the timeline .
'a -> Timeline<'a> -> unit
let timelineA = Timeline 1
timelineA |> TL.next 3
log (timelineA |> TL.last)
// 3
The update to timelineA
will trigger a reactive update of timelineB
according to the rule defined by the binary operation.
let double = fun a -> a * 2
// ① initialize timelineA
let timelineA = Timeline 1
// confirm the lastVal of timelineA
log (timelineA |> TL.last)
// 1
// ② the binary operation
let timelineB =
timelineA |> TL.map double
// confirm the lastVal of timelineB
log (timelineB |> TL.last)
// 2
//=====================================
// ③ update the lastVal of timelineA
timelineA
|> TL.next 3
// update to timelineA will trigger
// a reactive update of timelineB
// confirm the lastVal of timelineA & timelineB
log (timelineA |> TL.last)
// 3
log (timelineB |> TL.last)
// 6
Functional Reactive Programming (FRP) is a programming paradigm that uses mathematical expressions, specifically binary operations , as a means of implementing Reactive Programming .
Given the critical significance of Null in modern software development, I have dedicated a separate article to exploring its key concepts and benefits.
Both implementations define a Timeline<'a>
(F#) or Timeline<A>
(TypeScript) type that has:
- A current/last value (
_last
) - A list of functions to execute when the value is updated (
_fns
)
- Timeline Creation: Initialize a timeline with a starting value
- last: Get the current value
- next: Update the value and trigger all registered functions
- map: Transform a timeline's values using a function
- bind: Monadic binding operation (for chaining operations)
- unlink: Remove all registered functions
Both implementations also include combinators:
- Or/Any: Creates a timeline that resolves when any input timeline resolves
- And/All: Creates a timeline that resolves when all input timelines resolve
The main files showcase several common patterns:
- Simple value updates and reactions
- Transforming values with
map
- Chaining asynchronous operations with
bind
- Handling null values
- Combining timelines with logical operations
In TypeScript, programmers need to manually add types to their code, whereas in F#, this is not necessary. The screenshot above shows VSCode, but type annotations are automatically inferred by the F# compiler and displayed in the editor.
-
F# uses a more functional style with piping (
|>
) -
TypeScript implements the Timeline as an object with methods
-
The F# version has more detailed type handling
-
The TypeScript version offers more fluent method chaining, but requires an object-oriented implementation with methods.
-
In TypeScript, all types can be defined to allow null (💡 What is Null, Nullable and Option Types?), but in F#, reference types implicitly have null, while value types cannot have null. Although
System.Nullable
can be used, it lacks consistency in notation with reference type nulls, leading to code complexity. Therefore, it's often necessary to devise workarounds, such as converting value types to reference type objects.
type intObj = { // reference type object
value: int // containing value type (int)
}
// Initialize Timeline with Null
let timelineIntObj: Timeline<intObj>
= Timeline Null
// Map the Timeline
timelineIntObj
|> TL.map(fun value ->
if (isNullT value)
then log null
else log value
)
|> ignore
timelineIntObj |> TL.next {value = 1}
timelineIntObj |> TL.next {value = 2}
timelineIntObj |> TL.next {value = 3}
timelineIntObj |> TL.next Null
Timeline is a lightweight reactive programming library that implements a simple yet powerful observable pattern. It allows values to change over time while automatically propagating those changes to dependent computations.
The Timeline library is built around a central data structure called Timeline<'a>
which:
- Stores the most recent value of type
'a
- Maintains a list of callback functions that execute when the value changes
- Provides monadic and functor operations for functional composition
type Timeline<'a> =
{ mutable _last: 'a // Stores the most recent value
mutable _fns: list<'a -> unit> } // List of functions to execute on updates
Timeline<'a>
: Creates a new Timeline with an initial value// 'a -> Timeline<'a> let timeline = Timeline initialValue
-
TL.last
: Retrieves the current value from a Timeline// Timeline<'a> -> 'a let currentValue = timeline |> TL.last
-
TL.next
: Updates a Timeline with a new value and triggers all registered callbacks// 'a -> Timeline<'a> -> unit timeline |> TL.next newValue
-
TL.unlink
: Removes all registered callbacks from a Timeline// Timeline<'a> -> unit timeline |> TL.unlink
-
TL.map
: Functor map operation that transforms Timeline values// ('a -> 'b) -> Timeline<'a> -> Timeline<'b> let timelineB = timelineA |> TL.map Function
-
TL.bind
: Monadic bind operation that connects Timelines in sequence// ('a -> Timeline<'b>) -> Timeline<'a> -> Timeline<'b> let timelineB = timelineA |> TL.bind MonadicFunction
Retrieves the current value stored in the Timeline
. This is a simple accessor that returns the _last
field of the Timeline
record.
Updates the Timeline
with a new value and executes all registered callback functions with that value. This is the primary means of pushing new values into the reactive system.
- Purpose: Transforms the values of a
Timeline
using a provided function, producing a newTimeline
whose values are the results of that transformation. Think of it as "mapping" each value in the stream to a new value. - Mechanism:
- Initial Value: Creates a new
Timeline<'b>
instance. The initial value of this new timeline is the result of applying the transformation function ('a -> 'b
) to the current value of the inputTimeline<'a>
. - Propagation: Adds an observer function to the input
Timeline<'a>
. Whenever the input timeline updates, this observer does the following:- Takes the new value (
'a
). - Applies the transformation function (
'a -> 'b
) to the new value. - Updates the new
Timeline<'b>
(created in step 1) with the transformed value usingTL.next
.
- Takes the new value (
- Initial Value: Creates a new
- Key Features:
- One-to-One Transformation: Each value in the input timeline is directly transformed into one value in the output timeline.
- Always Creates a New Timeline:
map
always creates a newTimeline
instance to hold the transformed values. This is necessary because it's directly changing the type of the values flowing through. - Simple Transformations: Ideal for straightforward value transformations where you don't need to change the structure of the timeline itself (e.g., converting temperatures, formatting strings, extracting object properties).
- Functional Purity: Because of the one-to-one relationship, the map operation on Timeline can easily maintain functional purity.
- Purpose: Chains
Timeline
instances together, where the creation or selection of the nextTimeline
depends on the current value of the previousTimeline
. This allows for dynamic and conditional timeline behavior. It flattens aTimeline
ofTimeline
s into a singleTimeline
. - Mechanism:
- Initial Timeline: Calls the provided function (
'a -> Timeline<'b>
) with the current value of the inputTimeline<'a>
. This function returns aTimeline<'b>
. Crucially, thisTimeline<'b>
could be newly created or a pre-existing one. This is where the flexibility comes from. ThisTimeline<'b>
is the one that will be returned bybind
. - Propagation: Adds an observer function to the input
Timeline<'a>
. Whenever the input timeline updates:- Takes the new value (
'a
). - Calls the provided function (
'a -> Timeline<'b>
) using, a, to get a temporaryTimeline<'b>
. - Obtains new
Timeline<'b>
's current value. - Updates the timelineB returned in step 1 using the new value by calling
TL.next
.
- Takes the new value (
- Initial Timeline: Calls the provided function (
- Key Features:
- Dynamic Timeline Selection/Creation: The crucial difference! The function (
'a -> Timeline<'b>
) can:- Create a new
Timeline<'b>
on each update ofTimeline<'a>
. (Similar tomap
, but with the ability to create timelines with different structures/observers). - Return a pre-existing
Timeline<'b>
. This is what allows for efficient chaining without unnecessary timeline creation. You can conditionally return different timelines based on the input value. - Return a
Timeline<'b>
that's even based on some external state.
- Create a new
- Chaining and Sequencing: Enables complex workflows where the next step in the process depends on the result of the previous step. This is essential for asynchronous operations and conditional logic.
- Flattening: While
map
transformsTimeline<'a>
toTimeline<'b>
,bind
transformsTimeline<'a>
and a function that potentially creates lots of internalTimeline<'b>
. But returns only oneTimeline<'b>
. - Not Necessarily New Timelines: As you correctly pointed out,
bind
does not have to create new timelines on every update. This makes it much more powerful and efficient for complex scenarios.
- Dynamic Timeline Selection/Creation: The crucial difference! The function (
Removes all registered callback functions from the Timeline
, effectively disconnecting it from any dependent Timelines. This is important for preventing memory leaks when a Timeline is no longer needed.
Timeline implements a reactive programming pattern where:
- Changes to source Timelines automatically propagate to derived Timelines
- Computation chains can be constructed using
map
andbind
operations - Asynchronous processes can be sequenced and coordinated through Timeline chains
// Create source timeline
let source = Timeline 0
// Create a derived timeline that doubles the value
let doubled = source |> TL.map (fun x -> x * 2)
// Create another derived timeline that adds 10
let added = doubled |> TL.map (fun x -> x + 10)
// Update the source
source |> TL.next 5
// Now: source._last = 5, doubled._last = 10, added._last = 20
Timeline can be used to coordinate asynchronous operations:
// Implementation of setTimeout API, similar to JavaScript
open System.Timers
let setTimeout f delay =
let timer = new Timer(float delay)
timer.AutoReset <- false
timer.Elapsed.Add(fun _ -> f())
timer.Start()
// Timeline bind sequence
let timeline0 = Timeline Null
let timeline1 = Timeline Null
let timeline2 = Timeline Null
let timeline3 = Timeline Null
timeline0
|> TL.bind(fun value ->
if (isNullT value)
then ()
else
let f =
fun _ ->
let msg = "Hello"
log msg
timeline1
|> TL.next msg
setTimeout f 1000
timeline1
) // Return timeline1 directy to chain the next bind
|> TL.bind(fun value ->
if (isNullT value)
then ()
else
let f =
fun _ ->
let msg = value + " World!"
log msg
timeline2
|> TL.next msg
setTimeout f 2000
timeline2
) // Return timeline2 directy to chain the next bind
|> TL.bind(fun value ->
if (isNullT value)
then ()
else
let f =
fun _ ->
let msg = value + " Sequence ends."
log msg
timeline3
|> TL.next msg
setTimeout f 1000
timeline3
) // Return timeline3 directy to chain the next bind
|>ignore
timeline0
|> TL.next "Start!"
System.Console.ReadKey() |> ignore
// Keep the console window open in debug mode
Timeline
uses mutable fields for efficiency- Both
map
andbind
operations maintain references to their source Timelines, but onlybind
can directly reference Timelines defined outside of the function's scope, leveraging its monadic nature. - To prevent memory leaks, use
unlink
to clear callbacks when a Timeline is no longer needed
The Timeline library provides several advanced operations for combining and coordinating multiple Timelines. These operations implement logical combinators that create new Timelines based on the state of input Timelines.
Creates a Timeline that resolves to the first non-null value from either of the input Timelines.
// Type: Timeline<'a> -> Timeline<'a> -> Timeline<'a>
let combinedTimeline = TL.Or timelineA timelineB
Behavior:
- The resulting Timeline will initially contain
Null
- When either input Timeline receives a non-null value, the result Timeline is updated with that value (if the result Timeline is still null)
- The first non-null value "wins" and subsequent updates to either source Timeline are ignored
- If both source Timelines already have non-null values when
Or
is called, the result Timeline will get the value fromtimelineA
Creates a Timeline that resolves when both input Timelines have non-null values, combining their results into an AndResult
structure.
// Type: Timeline<'a> -> Timeline<'a> -> Timeline<obj>
let combinedTimeline = TL.And timelineA timelineB
Behavior:
- The resulting Timeline will initially contain
Null
- It updates only when both input Timelines have non-null values
- Results are combined into an
AndResult<'a>
structure which contains a list of all values - If either input Timeline returns to null, the result Timeline also returns to null
AndResult Structure:
type AndResult<'a> = { result: list<'a> }
Generalizes the Or
operation to work with a list of Timelines, resolving to the first non-null value from any of the input Timelines.
// Type: list<Timeline<'a>> -> Timeline<'a>
let combinedTimeline = TL.Any [timeline1; timeline2; timeline3]
Behavior:
- Equivalent to applying
Or
operations in sequence to the list of Timelines - Returns a Timeline that resolves to the first non-null value from any of the input Timelines
Generalizes the And
operation to work with a list of Timelines, resolving when all input Timelines have non-null values.
// Type: list<Timeline<obj>> -> Timeline<obj>
let combinedTimeline = TL.All [timeline1; timeline2; timeline3]
Behavior:
- Equivalent to applying
And
operations in sequence to the list of Timelines - Returns a Timeline that resolves only when all input Timelines have non-null values
- Results are combined into a single
AndResult
structure containing all values
let asyncOr1 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// Or binary operator
let (|||) = TL.Or
let timelineABC =
timelineA ||| timelineB ||| timelineC
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A" // "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C"
let asyncOr2 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// Any of these
let timelineABC =
TL.Any [timelineA; timelineB; timelineC]
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A" // "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C"
let asyncAnd1 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// And binary operator
let (&&&) = TL.And
let timelineABC =
timelineA &&& timelineB &&& timelineC
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C" // { result = ["A"; "B"; "C"] }
let asyncAnd2 =
let timelineA = Timeline Null
let timelineB = Timeline Null
let timelineC = Timeline Null
// All of these
let timelineABC =
TL.All [timelineA; timelineB; timelineC]
timelineABC
|> TL.map log
|> ignore
timelineA |> TL.next "A"
timelineB |> TL.next "B"
timelineC |> TL.next "C" // { result = ["A"; "B"; "C"] }
- The
Or
andAnd
operations create new Timelines and set up the appropriate mapping relationships - These operations use the
map
function internally to propagate updates - The
Any
andAll
operations reduce a list of Timelines using the corresponding binary operation - The
AndResult
type is used to accumulate and track results from multiple Timelines
Maximize the power of ChatAI !
By providing ChatAI with existing sample code in F# and TypeScript, it can translate to most languages.