Higher-Order Functions

A higher-order function takes a function as an argument, returns one, or both. map and filter are the familiar ones. Types-as-examples work well for data, because TJS can look at a value and check it. A function is harder: TJS can tell that a value is a function, but not what it will do until it is called. This page shows where TJS draws that line.

A reminder of the parameter syntax (the full rules are in TJS Syntax): name: example is a required parameter, and name = example is an optional one whose example is also its default. [''] means "an array of strings" and [0] "an array of integers". For a function parameter, the example is a function.

// The example is also the default: leave fn out and you get the
// identity function, which returns its argument unchanged.
// The returns are marked :! (type only, not tested); see below for why.
function mapStrings(arr: [''], fn = (x) => x):! [''] {
  return arr.map(fn)
}

// The default predicate keeps every element
function filterNumbers(arr: [0], predicate = (x) => true):! [0] {
  return arr.filter(predicate)
}

// Returns a NEW function: f applied to the result of g
function compose(f = (x) => x, g = (x) => x) {
  return (x) => f(g(x))
}

const double = (x) => x * 2
const addOne = (x) => x + 1

// compose(addOne, double) is a function: x -> (x * 2) + 1
const doubleThenAddOne = compose(addOne, double)

console.log('Mapped:', mapStrings(['hello', 'world'], (s) => s.toUpperCase()))
// Mapped: [ "HELLO", "WORLD" ]
console.log('Default fn:', mapStrings(['as', 'is']))
// Default fn: [ "as", "is" ]
console.log('Filtered:', filterNumbers([1, 2, 3, 4, 5, 6], (x) => x % 2 === 0))
// Filtered: [ 2, 4, 6 ]
console.log('doubleThenAddOne(5):', doubleThenAddOne(5))
// doubleThenAddOne(5): 11

// compose has no return example (see below), so a test states what it does
test 'compose applies g first, then f' {
  expect(compose(addOne, double)(5)).toBe(11)
  expect(compose(double, addOne)(5)).toBe(12)
  // no arguments: both defaults, the identity of the identity
  expect(compose()(7)).toBe(7)
}

What TJS checks

fn = (x) => x says three things: the parameter is a function, it is optional, and if you leave it out you get the identity function. The example is a real default, which is why mapStrings(['as', 'is']) works. Write fn: (x) => x instead and the parameter is required.

At the call, TJS checks the one thing it can check cheaply and honestly: is this a function? Pass a string, null or a number where fn belongs and you get a MonadicError back: an error object returned as the function's result instead of thrown (see Error Handling). You find out at the call, not from a TypeError three frames later inside arr.map.

What it does not check is the callback's own signature. (x) => x does not mean "takes one argument and returns the same type": any function passes. Whether a function does what a contract says is something you learn only by calling it, so TJS does not claim to know.

Why compose has no return example

On most TJS functions the return annotation is a worked example that is run as a test. function add(a: 2, b: 3): 5 makes TJS call add(2, 3) when the file is transpiled (which tjs check does), and the check fails if the answer is not 5.

That test calls your function, with the parameter examples as its arguments. A number, a string or an array is data, and TJS can hand it over as an argument. (x) => x is code: to use it as an argument TJS would have to run code written inside an annotation, and it never does that. So for a function with a function-valued example, TJS drops the signature test altogether. It does this silently: tjs check reports nothing, not even a skipped test.

That means a plain return annotation such as : [''] on mapStrings would document the return type and nothing more. It would pass even if it were wrong. Two honest options:

You also cannot write a function as a return example. function f(k: 2): (x) => x * k { … } is a syntax error: TJS takes the => after the return annotation as the start of an arrow function, which a function declaration cannot have. You can name a FunctionPredicate instead (function f(k: 2):! Unary, see below). That records the intent in the source, but nothing checks it, and tjs check reports the return type as any.

Callbacks keep their own checks

The checks live with each function, not with the caller. A TJS function passed as a callback still validates its own arguments wherever it ends up being called. A factory, a function that returns a function, is checked when you call it; the function it returns is not (more on that in the next section).

0.0 in these examples declares a float, which accepts any number; 0 would accept integers only. The String(...) wrappers turn an error into its readable message for printing.

// A TJS function passed as a callback keeps its own checks
function half(n: 0.0): 0.0 {
  return n / 2
}

function mapAll(xs: [0.0], fn = (x) => x):! [0.0] {
  return xs.map(fn)
}

console.log('mapAll([2, 4, 6], half):', mapAll([2, 4, 6], half))

// The parameter only promises "a function": a string is refused at the call
console.log('mapAll([2, 4], "half"):', String(mapAll([2, 4], 'half')))

// ...and `half` refuses a bad element itself, wherever it is called from
console.log("['8', 4].map(half):", ['8', 4].map(half).map(String))

// A factory is checked when it is called; the function it returns is plain JS
function multiplier(k: 2.0) {
  return (x) => x * k
}

const looseTriple = multiplier(3)
console.log('looseTriple(5):', looseTriple(5))
console.log("looseTriple('a'):", looseTriple('a'))
console.log("multiplier('3'):", String(multiplier('3')))

The third line of output shows that map did not stop at the bad element. half('8') returned an error value and map carried on, so the result holds one error message and the number 2 (both printed as strings by .map(String)). Errors that are returned rather than thrown work this way by design, and it is why you check results at the edge of a pipeline (see Error Handling).

Checking the function a factory returns

Only top-level functions validate their parameters: a top-level function, or a typed arrow assigned at top level (const h = (x: 0) => x). A function created inside another one is not checked, even with typed parameters. The arrow multiplier returns takes anything, which is why looseTriple('a') printed NaN rather than an error.

When the returned function needs a check, have it hand the work to a top-level typed function:

// The typed work lives at top level, where TJS checks it
function scaleBy(x: 2.0, k: 3.0): 6.0 {
  return x * k
}

// The factory's inner arrow only forwards to it
function scaler(k: 2.0) {
  return (x) => scaleBy(x, k)
}

const checkedTriple = scaler(3)
console.log('checkedTriple(5):', checkedTriple(5))
console.log("checkedTriple('a'):", String(checkedTriple('a')))

scaleBy has only data examples, so its return example is a real test: tjs check calls scaleBy(2.0, 3.0) and fails if the answer is not 6.0. The test block in the first example does the same job for compose, which cannot have one.

Describing a callback's shape

FunctionPredicate (see Declarations) declares a function type: its parameters and its return. Calling the declaration returns true or false, and it checks only whether the value is a function. The params and returns are not checked at call time: Unary(() => 'x') is true even though it takes no x and returns a string. They are a description for readers and tools; an exported FunctionPredicate becomes a function type in .d.ts output (export type Unary = (x: number) => number).

FunctionPredicate Unary {
  params: { x: 0 }
  returns: 0
}

console.log('Unary((n) => n + 1):', Unary((n) => n + 1)) // true
console.log("Unary(() => 'x'):", Unary(() => 'x')) // true: shape not checked
console.log("Unary('not a function'):", Unary('not a function')) // false

A FunctionPredicate name is not yet a parameter annotation. function run(fn: Unary) transpiles, but tjs check warns that Unary "could not be resolved to a runtime type", and the parameter is not checked at all. Until that changes, use a function example (fn = (x) => x) for the parameter, and call Unary(fn) yourself where you want the named check.