FunctionPredicate: Design Notes

How TJS describes the type of a function: what was proposed, what shipped, and what did not.

In brief

  • To type a callback parameter, give it a function example: function greet(format: (s: '') => ''). A typed function passed in is checked against that shape.
  • FunctionPredicate declares a named function type. Its runtime check is only "can this value be called?"; its parameters and return example are metadata for docs, .d.ts output and introspection.
  • function run(fn: Formatter), with Formatter a FunctionPredicate, checks nothing, not even callability. See What did not ship.

This began as a proposal; most of it shipped in a smaller form. For day-to-day use, see Declarations and Higher-order functions; this note records why it works the way it does.

A few terms used below. In TJS a parameter's annotation is an example value, not a type: 0 means integer, 0.0 any number, '' a string (see TJS syntax). The kind of an example is the type inferred from it: integer, number, string, and so on. Type is a named runtime type built from an example, and Generic is a Type with type parameters. A MonadicError is an error value a function returns instead of throwing; test for one with isMonadicError. fromTS is the TypeScript-to-TJS converter, and __tjs is the metadata TJS attaches to every function it transpiles.


The problem

A callback parameter needs a type. Before FunctionPredicate, TJS had no way to say "this parameter is a function taking a string and returning a string", and fromTS dropped TypeScript function types such as () => void entirely. Callbacks, event handlers and higher-order functions lost their type at exactly the boundary where a type helps most.

Design principles

  1. A function is a value, so it can be an example. 0 means "integer" and '' means "string"; a function with typed parameters should mean "a function shaped like this".
  2. A function type works like Type and Generic. It is a value you call to check a candidate, and it carries metadata that tools can read.
  3. The return contract is part of the signature. The three return markers (:, :?, :!) say how much the author promises about the result, so a function type records which one it expects.

The three return contracts

The markers on a function's return annotation (see Return annotations):

Marker Contract name What happens
: returns A worked example: the function is called with its parameter examples when the file is transpiled, and the result must equal the annotation.
:? checkedReturns The same worked example, plus a check of every result at runtime. A wrong result is returned as a MonadicError, not thrown.
:! assertReturns The return type only. Nothing runs and the result is never checked.

assertReturns is named the way a TypeScript as assertion is: the author asserts the return type and nothing verifies it.

A FunctionPredicate stores one of these names in its returnContract property. That property is metadata: a FunctionPredicate never calls a function, so nothing is tested, thrown or wrapped because of it, and the contract is never compared with a callback's.

What shipped

The FunctionPredicate declaration

A block declares a function type by its parameters and its return example:

FunctionPredicate Formatter {
  description: 'formats a string for a locale'
  params: { input: '', locale: 'en' }
  returns: ''
}

console.log(Formatter((s) => s.trim()))      // true
console.log(Formatter('not a function'))     // false
console.log(Formatter((a, b, c, d) => a))    // true: arity is not compared
console.log(Formatter.description)           // formats a string for a locale
console.log(Object.keys(Formatter.params).join(', '))  // input, locale
console.log(Formatter.returns === '')        // true
console.log(Formatter.returnContract)        // assertReturns

The runtime check is callability only. Formatter(x) is true for any function and false for anything else. Whether a function honours a signature is something you learn by calling it, and a type check must not call the value it checks.

The parameters and return example are used elsewhere: the generated .d.ts (see Declaration files), documentation, and introspection through .params, .returns and .returnContract. Where a callback's shape is actually checked is a parameter whose example is a function; see A function as a parameter example.

The block accepts four members:

Any other key is silently ignored. Writing checkedReturns: '' in place of returns: '' gives a type with no return example and the default contract; write returnContract: 'checkedReturns' instead.

Generic function types

A FunctionPredicate can take type parameters, as Generic does. T = 0 declares a type parameter whose default example is an integer. Calling the declaration with an example value substitutes it for T, so Mapper('') is a mapper from strings to strings. typeParamNames lists the declared parameters. The concrete type still checks callability only:

FunctionPredicate Mapper<T = 0> {
  params: { value: T }
  returns: T
}

const StringMapper = Mapper('')
console.log(Mapper.typeParamNames.join(', '))    // T
console.log(StringMapper.returns === '')          // true
console.log(StringMapper((s) => s), StringMapper(3)) // true false

The function form

FunctionPredicate Name(fn, 'description') was meant to take a type from an existing function's metadata. It takes only the description; the parameters, return example and contract are not copied, in any TJS program. Use the block form instead.

function next(n: 1): 2 { return n + 1 }

FunctionPredicate Step(next, 'one step forward')

console.log(Step.description)                 // one step forward
console.log(Object.keys(Step.params).length)  // 0: the parameters were not copied
console.log(Step(next), Step(null))           // true false

A function as a parameter example

The idea from the first principle did ship, through parameter examples rather than through FunctionPredicate. When a parameter's example is a function with typed parameters, the check on a passed value has two parts: it must be a function, and if it carries TJS metadata, its declared parameter kinds and return kind must match the example's.

function shout(s: 'hi'): 'HI!' { return s.toUpperCase() + '!' }
function next(n: 1): 2 { return n + 1 }
function zero(s: ''): 0 { return 0 }

// `format`'s example is a function, so its shape is the expected shape
function greet(format: (s: '') => '') { return format('hello') }

console.log(greet(shout))                  // HELLO!
console.log(greet((s) => s + '?'))         // hello?  (untyped: not compared)
console.log(isMonadicError(greet(next)))   // true: next declares an integer parameter
console.log(isMonadicError(greet(zero)))   // true: zero returns an integer
console.log(isMonadicError(greet(42)))     // true: not a function

Three decisions are visible here:

A ! at the start of the parameter list turns off every check on that function's parameters, this one included. function greet(! format: (s: '') => '') { … } accepts 42 for format without complaint. See Safety markers.

The same check applies when the parameter's default is a previously declared TJS function (function greet(format = shout)): the default's signature becomes the expected shape. Naming such a function as the example (function greet(format: shout)) also performs the check, but tjs check warns that shout "could not be resolved to a runtime type" and is unchecked. The warning is wrong here: the parameter is checked. Until that is fixed, use the arrow example or the default.

Conversion from TypeScript

fromTS now converts function types instead of dropping them:

TypeScript Converted TJS
type Formatter = (input: string) => string a FunctionPredicate Formatter { params: { input: '' } returns: '' } block
a member onChange: (v: number) => void onChange: FunctionPredicate('function', { params: { v: 0.0 } }) in the Type example
a parameter fn: (x: number) => string fn: FunctionPredicate('function', { params: { x: 0.0 }, returns: '' })

The last two rows use a call form: FunctionPredicate(name, { params, returns }) builds an unnamed function type inline, with 'function' as its name. A void return becomes no returns key, so the result is unconstrained.

A member converted this way is checked for callability when the enclosing Type is checked. A parameter converted this way is not checked. Converted code defaults to safety none, which turns off parameter checks for the whole file; and even with checks turned on, the inline FunctionPredicate(…) call is not read as a runtime type, so the parameter is treated as any. For a parameter, the conversion keeps the information for docs and .d.ts output only.

Declaration files

A FunctionPredicate block becomes a TypeScript function type in the generated .d.ts:

FunctionPredicate Formatter {        →   export type Formatter = (input: string, locale: string) => string;
  params: { input: '', locale: 'en' }
  returns: ''
}

What did not ship

A FunctionPredicate in a TJS program has no toJSONSchema(). That is not a gap: JSON Schema cannot describe a function.