ad88 d8" ,d 88 88 MM88MMM MM88MMM ,adPPYba, 8b,dPPYba, 88 88 a8" "8a 88P' "Y8 88 88 8b d8 88 88 88, "8a, ,a8" 88 88 "Y888 `"YbbdP"' 88
Version 0.9.20 (unstable)
Please note: The experience gained from this project has resulted in a completely new approach: scriptum.
ftor will need at least another six month to reach a more stable status (as of Feb., 2018). It has a great impact on the way Javascript is encoded, but there are no best practices yet, so conceptual details may still change.
ftor enables ML-like type-directed, functional programming with Javascript including reasonable debugging. In essence it consists of an extended runtime type system, a type validator and a typed functional programming library.
As opposed to a type checker, a type validator requires explicit function type signatures and assumes that they are correct for a given implementation. As soon as typed functions are called, it validates and unifies them to new types.
Regarding the library a separated API documentation is available, but still under construction.
Programming in an untyped environment sucks.
This is the still unfinished proof that a Haskell-like runtime type validator for Javascript is actually useful, not just for learning purposes but also for production.
- pluggable through proxy virtualization
- parametric polymorphism
- row polymorphism
- type-safe ADTs
- Scott encoded sum types including functional pattern matching
- newtype for single constructor, single field types
- homogeneous Arrays and Maps
- real Tuples and Records
- type hints for partially applied combinators
- strict type evaluation
A runtime type validator is useful during development but unwanted in production use. It is therefore crucial to be able to deactivate the validator on demand:
import * as F from ".../ftor.js";
// type validator is enabled by default
F.type(false);
As a result you must not create dependencies to the extended type system.
Javascript is not able to introspect function types and since ftor doesn't conduct type inference, we need to type functions manually, so that the type validator can combine them with automatically introspected types. This way ftor is able to unify types of arbitrarily complex function expressions. However, as mentioned before the validator assumes correct initial function types. It is solely the responsibility of the developer that type signatures match their implementations.
Writing explicit type annotations is laborious and requires a mature sense for types and their corresponding implementations. Hence the real power of ftor's type system will arise from the interaction with a typed functional library. Users of this library can focus on composing typed functions and combinators and the type validator deduces intermediate types automatically.
If you want to benefit from types you must avoid imperative constructs and native operators, because they remain untyped. Please note that the functional paradigm has a corresponding functional idiom for each imparative one, that is you don't lose anything but gain type-safety, referential transparency and thus equational reasoning.
As already mentioned you are responsible to define correct function types that match their corresponding implementations. ftor supports you in this process by verifying the plausibility of type annotations. The following types are rejected, because their is no implementation satisfying it:
[Please note: This feature isn't implemented yet, because it depends on a rather complex logical tautology check. Please bear with me!]
const id = Fun(
"(id :: a -> b)",
x => x
); // type error
const id_ = Fun(
"(id :: (a -> b) -> a -> c)",
x => f => f(x)
); // type error
These types are uninhabited and hence rejected.
- ftor is a type validator not a type checker
- it focuses on parametric and row polymorphism1 and doesn't support subtyping
- it mainly relies on nominal instead of structural typing2
- it is designed to facilitate purely functional programming
1also known as static duck typing
2Nominal typing means that types are distinguished by name rather than by structure
Type classes either require a compilation step or the runtime must have continuous access to the extended type information to conduct dynamic type class dispatching. As a pluggable type validator ftor doesn't meet these requirements and consequently cannot support type classes. It uses explicit type dictionary passing instead, which allow multiple type classes per type. Abandoning the singleton property may be a burden or a relief - this depends on the problem you're trying to solve.
ftor neither supports higher order nor higher rank types. I am not sure if such sophisticated type extensions make sense in the context of Javascript and type validation. Maybe sometime.
Fantasy Land has done a great deal to spread the functional paradigm in the Javascript community. However, its focus on type classes based on the prototype system isn't compliant with ftor's approach.
ftor doesn't support the following native Javascript types, because they are inherently imperative:
Promise
Iterator
Generator
Javascript is inherently imperative and can perform side effects everywhere, not only at ;
. It is ultimately the responsibility of the developer to confinve themselves to pure functions. Pure functions can be replaced with their return values without altering the behavior of the program. This property is called referential transparency, which comes along with equational reasoning, another very desirable property.
For common types like Array
and Record
ftor restricts the possibilty of mutation rather than imposing strict immutability. Algebraic data types on the other hand are immutable and other functional data types like Tries
will follow.
- incorporate a tautoligy check for explicit type signatures
- allow type system extensions through CONSTRAINED dynamic types (e.g. variadic compostion:
... (c -> d) -> (b -> c) -> (a -> b)
) - add section from CPS to Scott encoding to readme
- provide common functional combinators/patterns
- revise error messages and underlining
- pretty print unified types
- add unit tests
- replace monolithic parser with functional parser combinators
- add homogeneous Set type
- incorporate a special effect type / corresponding runtime
- add persistant data structures
Let's get to the extended types without any further ado.
You can easily create typed functions with the Fun
constructor. It takes a mandatory type signature and an arrow function - that's all:
const add = Fun(
"(add :: Number -> Number -> Number)",
m => n => m + n
);
add(2) (3); // 5
add(2) ("3"); // type error
Please note that the name portion (add ::
) in the signature is optional and used to give the corresponding anonymous function sequence a name.
ftor relies on functions being pure1 but cannot enfoce this characteristic in Javascript. There will be a special data type in the near future, with which effects can be expressed in such a pure environment.
ftor doesn't support multi-argument functions but only functions in curried form, that is sequences with exactly one argument per call.
Currying leads to lots of partially applied anonymous functions throughout the code. One of the most annoying aspects of working with such anonymous functions in Javascript consists in debugging them. For that reason ftor automatically assigns the name portion of type signatures to each subsequent lambda:
const add = Fun(
"(add :: Number -> Number -> Number)",
n => m => n + m
);
add(2).name; // "add"
A lot of people are concerned about the readability of the typical curry function call pattern (fun(x) (y) (z)
). It is considered as less readable than calling multi-argument functions (fun(x, y, z)
) and non-idiomatic in general.
I find the criticism exaggerated and above all emotionally motivated. It is much more important that currying entails great benefits like partial application and abstraction over arity. Moreover it greatly simplyfies the design of the type validator.
If you are concerned about performance and micro optimizations rather than code reuse, productivity and more robust programs you should prefer imperative algorithms and mutations anyway. Flow or TypeScript are more suitable in this case.
You can, however, utilize variadic functions using the rest parameter and destructuring assignment to mimic multi-argument functions:
const repeatStr = Fun(
"(repeatStr :: ...[String, Number] -> String)",
(...[s, n]) => Array(n + 1).join(s)
);
repeatStr("x", 3); // "xxx"
Verbose error messages provide a better debugging experience:
const inc = Fun(
"(inc :: Number -> Number)",
n => n + 1
);
inc(true); // throws the following type error
Uncaught TypeError: inc expects
(inc :: Number -> Number)
^^^^^^
Boolean received
at _throw (<anonymous>:3541:9)
at verifyArgT (<anonymous>:1884:34)
at Object.apply (<anonymous>:1680:22)
at <anonymous>:1:1
Please not that both error messages themselves and their pretty printing is quite bad at the moment and will be revised any time soon.
ftor handles function call arities strictly:
const inc = Fun(
"(inc :: Number -> Number)",
n => n + 1
);
inc(); // arity error
inc(2); // 3
inc(2, 3); // arity error
You can define variadic functions using the rest parameter:
const sum = Fun(
"(sum :: ...[Number] -> Number)",
(...ns) => ns.reduce((acc, n) => acc + n, 0)
);
sum(); // 0
sum(1, 2, 3); // 6
sum(1, "2"); // type error
Usually the rest parameter constructs an homogeneous array. As mentioned in the curried function section you can also let it construct a tuple if you want to mimic multi-argument functions.
You can explicitly express non-strict evaluation with thunks:
const thunk = Fun("(() -> String)", () => "foo" + "bar");
thunk(); // "foobar"
thunk("baz"); // arity error
ftor supports parametric polymorphism:
const k = Fun(
"(k :: a -> b -> a)",
x => y => x
);
const snd = Fun(
"(snd :: a -> a -> a)",
x => y => y
);
k(true) (false); // true
k("foo") ("bar"); // "foo"
k("foo") (123); // "foo"
snd(true) (false); // false
snd("foo") ("bar"); // "bar"
snd("foo") (123); // type error
a
and b
are type variables, that is they can be substituted with any type. They can, but do not have to be of different type:
Let's treat functions the same way as data. The following applicator helps to illustrate the underlying principle:
const ap = Fun(
"(ap :: (a -> b) -> a -> b)",
f => x => f(x)
);
const inc = Fun(
"(inc :: Number -> Number)",
n => n + 1
);
const toStr = Fun(
"(toStr :: Number -> String)",
n => String(n)
);
ap(inc) (2); // 3
ap(inc) ("2"); // type error
ap(toStr) (2); // "3"
ap(toStr) (true); // type error
The type validator immediately evaluates partially applied functions and is therefore able to throw type errors eagerly:
const ap_ = Fun(
"(ap_ :: (a -> a) -> a -> a)",
f => x => f(x)
);
const inc = Fun(
"(inc :: Number -> Number)",
n => n + 1
);
const add = Fun(
"(add :: Number -> Number -> Number)",
m => n => m + n
);
const toArr = Fun(
"(toArr :: a -> [a])",
x => Arr([x]) // typed array
);
ap_(inc); // passes
ap_(add); // type error
ap_(toArr); // type error
As soon as you combine monomorphic and polymorphic curried functions in various ways, you quickly lose track of the partial applied function's intermediate types:
const comp = Fun(
"(comp :: (b -> c) -> (a -> b) -> a -> c)",
f => g => x => f(g(x))
);
const inc = Fun(
"(inc :: Number -> Number)",
n => n + 1
);
comp(inc); // ?
This is still a trivial case but it may not be so easy to solve for someone unfamiliar with the functional paradigm. Let's ask ftor for help:
comp(inc) [TS]; // "(comp :: (a -> Number) -> a -> Number)"
Just use the TS
Symbol to access the current type signature of a typed function. Let's look at a more complex example:
const comp = Fun(
"(comp :: (b -> c) -> (a -> b) -> a -> c)",
f => g => x => f(g(x))
);
const compx = comp(comp),
compy = comp(comp) (comp);
compx[TS]; // "(comp :: (a -> b0 -> c0) -> a -> (a0 -> b0) -> a0 -> c0)"
compy[TS]; // "(comp :: (b1 -> c1) -> (a0 -> a1 -> b1) -> a0 -> a1 -> c1)"
You can derive from the type signatures that compx
takes a binary function, a value, an unary function and another value, whereas compy
takes an unary and then a binary function and two values. As a matter of fact compy
is a pretty useful function, since it allows us to use a binary function as the inner one of the composition.
Instead of examining implementations we can stick with type signatures to comprehend complex function expressions. By leaving implementation details behind, we've reached another level of abstraction and ftor helps us to keep track of the right types.
If the type signature of an higher order function returns a type variable, this means it can return any type thus also another function. As a result such higher order function types can abstract over the passed function argument's arity:
const ap = Fun(
"(ap :: (a -> b) -> a -> b)",
f => x => f(x)
);
const add = Fun(
"(add :: Number -> Number -> Number)",
n => m => n + m
);
const k = Fun(
"(k :: a -> b -> a)",
x => y => x
);
ap(add) (2) (3); // 5
ap(k) ("foo") ("bar"); // "foo"
Even though ap
merely accepts unary functions it can handle function arguments of arbitrary arity.
I think that fixed-point combinators and anonymous recursion are good candidates for testing the expressiveness of a type system. I chose the simplified version of the Y
combinator for this practice and since Javascript is a strictly evaluated language, I have to implement the eta-expanded version:
const fix = Fun(
"(fix :: ((a -> b) -> a -> b) -> a -> b)",
f => x => f(fix(f)) (x)
);
const fact = fix(Fun(
"(fact :: (Number -> Number -> Number) -> Number -> Number -> Number)",
rec => acc => n => n === 0 ? acc : rec(n * acc) (n - 1)
)) (1); // acc's initial value
fact(5); // 120
It took me a while to comprehend the type machinery in this case, so don't be discouraged.
You can create typed arrays with the Arr
constructor. Unlike Fun
you don't have to provide an explicit type signature but let the type validator introspect the type for you:
const xs = Arr([1, 2, 3]);
const ys = Arr(["foo", "bar", "baz"]);
xs[TS]; // "[Number]"
ys[TS]; // "[String]"
Please note that an empty typed array has the polymorphic type [a]
.
Typed arrays must be homogeneous, that is all element values must be of the same type:
Arr([1, "foo", true]); // type error
They must have a continuous index without gaps:
const xs = [1, 2, 3];
xs[10] = 4;
Arr(xs); // type error
You must not access unknown properties:
const xs = Arr([1, 2, 3]);
xs[0]; // 1
xs[10]; // type error
ys.concat; // function
ys.foo; // type error
Duck typing for properties with numeric keys works as usual:
const xs = Arr([1, 2, 3]),
x = 0 in xs ? xs[0] : 0, // 1
y = 10 in xs ? xs[10] : 0; // 0
However, you must not duck type with non-numeric keys:
const xs = Arr([1, 2, 3]),
foo = "foo" in xs ? xs.foo : false;
Other forms of meta-programming are restricted as well:
const xs = Arr([1, 2, 3]);
Object.keys(xs); // type error
As a general advice typed arrays should be used as arrays rather than plain old Javascript objects.
Typed arrays are immutable for properties with non-numeric keys:
const xs = Arr([1, 2, 3]);
xs.foo = "bar"; // type error
Indexed properties can be added or removed as long as there are no index gaps:
const xs = Arr([1, 2, 3]),
ys = Arr([1, 2, 3]);
xs.push(4); // passes
xs[10] = 5; // type error
delete ys[2]; // passes
delete ys[1]; // type error
Mutations must not alter the typed array's type:
const xs = Arr([1, 2, 3]);
xs[1] = 20; // 20
xs[1] = "2"; // type error
ftor prevents implicit type conversions whenever possible:
const xs = Arr([1, 2, 3]),
s = xs + "!"; // type error
Since there is no way to distinguish implicit from explicit conversions you unfortunatelly have to convert types manually.
You can pass typed arrays to typed functions as usual:
const append = Fun(
"(append :: [a] -> [a] -> [a])",
xs => ys => xs.concat(ys)
);
const xs = Arr([1, 2]),
ys = Arr([3, 4]),
zs = Arr["foo", "bar"]);
append(xs) (ys); // [1, 2, 3, 4]
append(xs) (zs); // type error
Please don't use the delete
operator or the corresponding Reflect.deleteProperty
function, because they silently produce index gaps. Since Array.prototype.pop
and Array.prototype.unshift
internally also use delete
I cannot disable it by default.
const xs = Arr([1, 2, 3]);
delete xs[2]; // passes, but evil
Since typed maps share a lot of properties with typed arrays, I am going to focus on the differences.
You can create a typed maps by passing an ES2015 map to the _Map
constructor:
const m = _Map(new Map([["Kalib", 42], ["Liz", 38], ["Dev", 35]]));
m.get("Liz"); // 42
m.get("Byron"); // type error
m.has("Byron"); // false
m.set("Byron", 30); // passes
m.set("Zara", "50"); // type error
m[TS]; // "{String::Number}"
The type signature of typed maps differs from that of typed records in that the key value pair is seperated by two consecutive colons without spaces.
Please note that the type of the empty typed map is {k::v}
.
You can pass typed maps to typed functions as usual:
const getOr = Fun(
"(getOr :: Number -> String -> {String::Number} -> Number)",
n => k => m => m.has(k) ? m.get(k) : n
);
const m = _Map(new Map([["Kalib", 42], ["Liz", 38], ["Dev", 35]]));
getOr(0) ("Dev") (m); // 35
getOr(0) ("Byron") (m); // 0
Since typed records share a lot of properties with typed arrays, I am going to focus on the differences.
Use the Rec
constructor and pass it a plain old Javascript object as argument to construct a typed record:
const r = Rec("{foo: "abc", bar: 123}");
r.foo; // "bar"
r.baz; // 123
r[TS]; // "{foo: String, bar: Number}"
Please note that empty records are not allowed.
Typed records are mutable but sealed, that is all properties are determined at declaration time:
const r = Rec("{foo: "abc", bar: 123}");
r.foo = "FOO"; // "FOO"
r.baz = true; // type error
As with typed arrays record fields must not change their type during mutation:
const r = Rec("{foo: "abc", bar: 123}");
r.foo = true; // type error
Since record types are sealed and you should know your types in the typed environment provided by ftor, there is no need for duck typing in conjunction with typed records. In fact, ftor raises an type error for any corresponding attempt:
const r = Rec("{foo: "abc", bar: 123}");
"foo" in r; // type error
You can pass typed records to typed functions as usual:
const showName = Fun(
"(showName :: {first: String, last: String} -> String)",
person => `${person.first} ${person.last}`
);
const o = Rec({first: "John", last: "Doe"}),
p = Rec({first: "John", last: null}),
q = Rec({first: "John"}),
showName(o); // "John Doe"
showName(p); // type error
showName(q); // type error
So far all records have a static type, which makes their handling quite awkward:
const showName = Fun(
"(showName :: {first: String, last: String} -> String)",
person => `${person.first} ${person.last}`
);
const o = Rec({first: "John", last: "Doe", age: 30});
showName(o); // type error
As you can see typed records require exact type matches. That is, of course, intolerable.
Fortunately, with row polymorphism there is a property that offers more flexibility:
const showName = Fun(
"(showName :: {first: String, last: String, ..r} -> String)",
o => `${o.first} ${o.last}`
);
const o = Rec({first: "John", last: "Doe", age: 30}),
p = Rec({first: "Jane", last: "Doe", gender: "f"});
showName(o); // "John Doe"
showName(p); // "Jane Doe"
r
is a so-called row variable, which includes the types of all unnecessary properties. Apart form that row variables act like any other type variable in a parametric polymorphic function type:
const showName = Fun(
"(showName :: {first: String, last: String, ..r} -> {first: String, last: String, ..r} -> String)",
o => p => `${o.first} ${p.last}`
);
const showName_ = Fun(
"(showName_ :: {first: String, last: String, ..r} -> {first: String, last: String, ..s} -> String)",
o => p => `${o.first} ${p.last}`
);
const o = Rec({first: "Sean", last: "Penn", age: 60}),
p = Rec({first: "Juliette", last: "Binoche", age: 45}),
q = Rec({first: "Stan", last: "Kubrick", gender: "m"});
showName(o) (p); // "Sean Binoche"
showName(o) (q); // type error
showName_(o) (p); // "Sean Binoche"
showName_(o) (q); // "Sean Kubrick"
Row polymorphism is also known as static duck typing, that is to say duck typing with static type guarantees.
Since typed tuples share a lot of properties with typed records, I am going to focus on the differences.
You can create a typed tuple by passing an array to the Tup
constructor:
const t = Tup([123, "foo", true]);
t[0]; // 123
t[1]; // "foo"
t[TS]; // "[Number, String, Boolean]"
The type signature of typed tuples differs from that of typed arrays in that there are several fields.
Please note that typed tuples must not be empty but have to contain at least 2 fields.
You can pass typed tuples to typed functions as usual:
const fst = Fun(
"(fst :: [a] -> a)",
t => t[0]
);
const snd = Fun(
"(snd :: [a] -> a)",
t => t[1]
);
const t = Tup([123, "foo", true]);
fst(t); // 123
snd(t); // "foo"
ADTs allow you to declare sums of products, that is you can declare sum types (aka tagged unions), product types and any combination of them. ftor uses Scott encoding to express sum types in Javascript and plain old Javascript objects for single constructor/field types.
Scott encoding entails somewhat scary type signatures. However, you can deduce them in a rather mechanical way, because their types have a recurring structure across different ADTs. Scott encoding is definitly worth the price, because it enables functional pattern matching.
The Reader
type is a common algebraic data type with a single data constructor and field. It uses the simple Data1
constructor:
const Reader = Data1(
function Reader() {}, "run",
"(Reader :: (e -> a) -> Reader<e, a>)"
) (Reader => f => Reader(f));
const runReader = Fun(
"(runReader :: Reader<e, a> -> e -> a)",
tf => x => tf.run(x)
);
const tf = Reader(inc);
tf[TS]; // "Reader<Number, Number>"
runReader(tf) (5); // 6
runReader(tf) ("foo"); // type error
In case you're wondering, the applicative and monad instance of the Reader
type is much more useful.
The Option
type is a simple sum type. It is constructed with Type
and Data
, which are the type and data constructor respectively. As opposed to Reader
sum types are based on Scott encoding:
const Option = Type(
function Option() {},
"Option<a>",
"({Some: (a -> r), None: r} -> r)"
);
const Some = Data(
"(Some :: a -> Option<a>)",
x => Option(cases => cases.Some(x))
);
const None = Option(cases => cases.None);
const runOption = Fun(
"(runOption :: {Some: (a -> r), None: r} -> Option<a> -> r)",
cases => tx => tx.run(cases)
);
const inc = Fun(
"(Number -> Number)",
n => n + 1
);
const safeInc = runOption(
Rec({
Some: inc,
None: 0
})
);
const brokenSafeInc = runOption(
Rec({
Some: inc,
Foo: 0
})
);
const tx = Some(5);
const ty = None;
safeInc(tx); // 6
safeInc(ty); // 0
brokenSafeInc(tx); // type error
List
is both a sum (Cons
/Nil
) and a product, because Cons
accepts more than one argument:
const List = Type(
function List() {},
"List<a>",
"({Cons: (a -> List<a> -> r), Nil: r} -> r)"
);
const Cons = Data(
"(Cons :: a -> List<a> -> List<a>)",
x => tx => List(cases => cases.Cons(x) (tx))
);
const Nil = List(cases => cases.Nil);
const runList = Fun(
"(runList :: {Cons: (a -> List<a> -> r), Nil: r} -> List<a> -> r)",
cases => tx => tx.run(cases)
);
const empty = runList(
Rec({
Cons: F.Fun("(a -> List<a> -> Boolean)", x => tx => false),
Nil: true
})
);
const xs = Cons("foo") (Nil);
const ys = Nil;
empty(xs); // false
empty(ys); // true
Moreover, List
is a recursive data type. You can easily define recursive and even mutual recursive types with Scott encoded ADTs.
- Explore issues caused by ftor's use of proxies with regard to object identity
- Note that creating dependencies to ftor's pluggable type system is bad