A wide ranging comparison-by-example of the typesystem features in various FP languages. Initially Purescript and Typescript.
The aim is compare the things that overlap. We don't want this comparison table to turn into this -
Purescript doesn't support untagged unions. But it can do something similar with a library. TODO: Comapre and contrast with Typescript.
Typescript
type t1 = 1 | 2 | 3
type t2 = string | number
type t3 = t1 | t2
In all these cases there is no boxing.
Refinement of the type is done with Flow sensitive typing
E.g. - Typescript
// t :: t2
if(typeof t == "number") {
// Here the type of t is refined to number
}
This can also work for user defined types with User defined type guards. These however are completely unsafe and the compiler does not check if your type check is correct. This is effectively unsafeCoerce but localised to the type check function.
e.g. - Typescript
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
Structural type narrowing is possible with in
operator to check for fields -
Typescript -
function move(pet: Fish | Bird) {
if ("swim" in pet) {
return pet.swim();
}
return pet.fly();
}
Purescript
data Shape = Square Number | Rectangle Number Number | Circle Number Number
area :: Shape -> Number
area s = case s of
Square size -> size * size
Rectangle width height -> width * height
Circle radius -> Math.PI * radius * radius
Typescript requires defining records with a literal discriminant property for each variant.
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
// In the following switch statement, the type of s is narrowed in each case clause
// according to the value of the discriminant property, thus allowing the other properties
// of that variant to be accessed without a type assertion.
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
}
Typescript's implementation is clearly clunkier, but it does allow discriminating on multiple properties, as well as discriminating on a subset of the variants.
Typescript
interface FailState {
error: "1";
msg: string;
}
interface LoadingState {
error: "0";
}
interface SuccessState {
error: "0";
value: number;
}
type State = FailState | LoadingState | SuccessState;
function processState(state : State) {
switch (state.error) {
case "0": {
console.log("We did not fail!");
break;
}
case "1": {
console.log("We failed with ", state.msg);
break;
}
}
}
processState({ msg: "unknown error", error: "1" });
Typescript supports Variants natively with untagged unions as shown in the previous examples. Purescript can have variants with the Purescript-variant library.
TODO: Elaborate.
Challenge - declare a type which is similar to a previously defined type, but excludes a variant branch.
More specifically, given a type -
data T = A | B | C Int
Now define a type function to generate a type U which is equivalent to a type -
data U = B | C Int
In Typescript you can define the second type in terms of the first like this -
type U = Exclude<T, 'a'>
const f = (t: T): U => t === 'A' ? 'B' : t
In Purescript we can denote the general relationship between any two data variants as long as the first has the field A
and the second has the corresponding field B
-
_A = (SProxy :: _ “A”)
_B = (SProxy :: _ “B”)
f :: forall r. Variant (A::String | r) -> Variant (B::String | r)
f = on _A (inj _B) identity
Homogenous records Challenge! - Write a function signature where the input is some arbitrary record and the output is a record with the same fields but all types converted to Number -
Typescript version uses Index types
const f = <O>(_: {[K in keyof O]: number}) => ...
Challenge: Represent a record that does NOT have a particular label -
Typescript version uses never
. The following function will not accept a record with field foo
.
const f = (_: Record<string, number> & {foo?: never}) => {}
Challenge: Represent a record which is the union of two arbitrary records -
Typescript again uses index types to great effect! The following function will accept three records where the third must necessarily be a union of the first two -
const f = <A extends unknown>(a: A, b: {[K in keyof A]: A[K] | null}) => {}
Challenge: Can we add fields dynamically, and have them be tracked? For example, can you write a function addField :: Record -> String -> a -> Record
so that the output record is the input record with a field added?
Typescript can do it but by using records instead of fieldnames.
So this doesn't work -
const add = <O, K extends string, V>(o: O, k: K, v: V): O & {[K]: V} => ({...o, [k]: v})
const set = <O, K extends keyof O>(o: O, k: K, v: O[K]): O => ({...o, [k]: v})
const res = set(add({a:1, c:'as'}, 'b', 3), 'b', 33)
console.log(res)
But this does -
const add = <A, B>(a: A, b: B) => ({...a, ...b})
const set = <O, K extends keyof O>(o: O, k: K, v: O[K]): O => ({...o, [k]: v})
const res = set(add({a:1, c:'as'}, {b: 3}), 'b', 33)
const res2 = set(add({a:1, c:'as'}, {b: 3}), 'c', 33) // fails
const res3 = set(add({a:1, c:'as'}, {b: 3}), 'c', 'string works')
const res4 = set(add({a:1, c:'as'}, {b: 3}), 'd', 'string works') // fails
One of the newer shiny things in typescript is Uppercase
, Capitalise
etc.
E.g. -
type A = Uppercase<'foo'> // Is the same as type A = 'FOO'
Also you can do -
type Getter<A extends string> = `get${Capitalize<A>} `
type B = Getter<'fooBar'> // Becomes 'getFooBar'
// And use `never` to disallow
type FromGetter<A extends string> = A extends get${infer B} ? Uncapitalize<B> : never
type C = FromGetter<'willFail'>
type D = FromGetter<'getThisWillWork'>;
Dynamic field manipulation is possible in Purescript as well but clunkier. See for example https://qiita.com/kimagure/items/4f5c6054870f631ff768.