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.FunctionPredicatedeclares a named function type. Its runtime check is only "can this value be called?"; its parameters and return example are metadata for docs,.d.tsoutput and introspection.function run(fn: Formatter), withFormatteraFunctionPredicate, 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
- A function is a value, so it can be an example.
0means "integer" and''means "string"; a function with typed parameters should mean "a function shaped like this". - A function type works like
TypeandGeneric. It is a value you call to check a candidate, and it carries metadata that tools can read. - 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:
params: the parameter examples.returns: the return example. This key holds only the example value; it does not select thereturnscontract.returnContract: one of the three contract names above. The default is'assertReturns', whateverreturnsholds.description: the default is the declaration's name.
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:
- Untyped functions pass. A plain arrow such as
(s) => s + '?'has no metadata to compare, so only callability is checked. Rejecting untyped functions was considered and turned down, because it would make every plain JavaScript callback an error. - The check runs at the call that passes the function, not on each invocation of it. A callback with the wrong shape is one error at the boundary, not one error per invocation.
- Kinds must match exactly where both sides declare one: an
integerparameter does not match anumber(0.0) one. A side whose kind isanymatches anything. Parameter count is not compared: a callback may declare fewer or more parameters than the example, and only the positions both declare are checked.
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
FunctionPredicatename as a parameter annotation.function run(fn: Formatter)transpiles, buttjs checkwarns thatFormatter"could not be resolved to a runtime type", and the parameter is not checked at all, not even for callability. Use a function example for the parameter, and callFormatter(fn)yourself where you want the named check. - Shape matching inside
FunctionPredicate. The proposal had it compare arity, parameter types, return type and return contract against the candidate's__tjsmetadata. TJS programs check callability only. (A separate runtime implementation compares arity and parameter kinds, but TJS programs never use it; see Type identity.) - Return-contract strictness. The proposal ranked the contracts (
checkedReturnssatisfies any requirement,assertReturnsonly itself) and would have refused a callback whose contract was too weak.returnContractis recorded and never compared. - Copying metadata in the function form, as shown above.
A FunctionPredicate in a TJS program has no toJSONSchema(). That is not a gap: JSON
Schema cannot describe a function.