Quick context: Validator<T>
is a function that returns a Result<T>
:
type Validator<T> = (value: any) => Result<T>;
When sharing some of this with a coworker to help figure out some type
questions they quickly pointed out that this is in fact a Parser (thanks Dennis). These are things an informally trained developer (me) probably should have been able to identify at this point in their career.
Mapping the understanding of what a Parser is to what I had named it caused confusion. So all things Validator<T>
have become Parser<T>
. Naming: one of the two hard things.
Combining more than Two Parsers
In the Parser<T>
library the function oneOf
accepts two Parser<T>
types and returns the union of them:
function oneOf<A,B>(a: Parser<A>, Parser<B>): Parser<(A|B)> {
return value => mapFailure(a(value), () => b(value));
}
A more complex Parser<T>
is now created out of simpler ones.
const isStringOrNumber = oneOf(isString, isNumber);
TypeScript can infer that isStringOrNumber
has the type of Parser<string|number>
.
This works great when combining two parsers, but when more than two are combined with oneOf
it requires nested calls:
const isThing = oneOf(isNull, oneOf(isPerson, isAnimal));
Assuming isPerson
is Parser<Person>
and isAnimal
is Parser<Animal>
, const isThing
is inferred by TypeScript to be:
type Parser<null | Person | Animal>
Each additional Parser<T>
requires another call of oneOf
. Writing a oneOf
that takes one or more Parser<T>
types is straight forward:
function oneOf(parser, ... parsers) {
return value => parsers(
(result, next) => mapFailure(result, () => next(result)),
parser(value)
)
}
However, writing the correct type signature for this function was beyond my grasp.
My first attempt I knew couldn’t work:
function oneOf<T>(parser: Parser<T>, ...parsers: Parser<T>[]): Parser<T> {
In use, TypeScript’s inference was not happy:
const example = oneOf(isString, isNumber, isBoolean);
Types of property 'value' are incompatible. Type 'number' is not assignable to type 'string'.
The T
was being captured as string
because the first argument to oneOf
is a Parser<string>
. However isNumber
is a Parser<number>
, so the two T
did not match and tsc
was not happy. Removing the first parser: Parser<T>
didn’t help.
If TypeScript is told what the union is, then everything is ok:
const example = oneOf<string|number|boolean>(isString, isNumber, isBoolean);
But the best API experience is one in which the correct type is inferred.
After varying attempts of picking out similar cases in TypeScript’s Advanced Types I gave up and posed the question in the company’s #typescript Slack channel.
The magical internet people debated about Parser<T>
and Result<T>
so I tried to simplify things to the “base case” and got rid of Result<T>
:
type Machine<T> = () => T
Is it possible to create a function signature such that a list of Machine<*>
s of differing <T>
s via variadic type arguments could infer the union Machine<T1|T2|T3|...>
:
function oneOf(... machines: Array<Machine<?>>>): Machine<(UNION of ?)> {
The magical internet people came up with a solution (thank you, Tal).
type MachineType<T> = T extends Machine<infer U> ? U : never;
function<M extends Machine<any>[]>(...machines: M): Machine<MachineType<M[number]>> {
After mapping it into the Parser
domain, It worked!
type ParserType<T> = T extends Parser<infer U> ? U : never;
function<P extends Parser<any>[]>oneOf(...machines: P): Parser<ParserType<P[number]>> {
const example = oneOf(isNumber, isString, isBoolean);
Running tsc
passed, and the inferred type of const example
is:
const example: (value: any) => Result<string | number | boolean>
Now to understand why it works.
Conditional Types: ParserType<T>
The first thing to understand is ParserType<T>
, which uses a Conditional Type:
type ParserType<T> = T extends Parser<infer U> ? U : never;
This is essentially a function within the type analysis stage of TypeScript (somewhat analogous to Flow’s $Call
utility type). My first understanding of this reads as:
Given a type
T
, if it extendsParser<infer U>
returnU
, otherwisenever
.
Using ParserType
with any Parser<T>
will give the type of T
. So given any function that is a Parser<T>
, the type of <T>
can be inferred.
Within the
Type inference in conditional typesextends
clause of a conditional type, it is now possible to haveinfer
declarations that introduce a type variable to be inferred. Such inferred type variables may be referenced in the true branch of the conditional type. It is possible to have multipleinfer
locations for the same type variable.
Take an example parsePerson
parser which is defined using objectOf
:
const parsePerson = objectOf({
name: isString,
email: isString,
metInPerson: isBoolean
});
type Person = ParserType<typeof parsePerson>;
// This is ok!
const valid: Person = {
name: 'Nausicaa',
email: 'nausica@valleyofthewind.website',
metInPerson: false,
};
// This fails!
const invalid: Person = {}; // Type Error
type Person
is inferred to be:
type Person = {
name: string;
email: string;
metInPerson: boolean;
}
const invalid: Person
fails because:
Type '{}' is missing the following properties from type '{ name: string; email: string; metInPerson: boolean; }': name, email, metInPerson
So now the return value of oneOf
is almost understood:
: Parser<ParserType<P[number]>>
This says:
Returns a
Parser<T>
whoseT
is theParserType
ofP[number]
.
Well what is P[number]
?
Mapped Types
In TypeScript, Mapped Types allow one to take the key and value types of one type, and transform them into another.
If you’ve used Partial<T>
or ReadOnly<T>
, you have used a Mapped Type. The example implementations of those are given as:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
type Partial<T> = {
[P in keyof T]?: T[P];
}
Given a type with an index, the type that is used for the index’s value can be accessed using its key type:
type MyIndexedType = {[key: number]: (number|boolean|string)};
type ValueType = MyIndexedType[number];
In this example ValueType
will have the type (number|boolean|string)
.
In the return signature of oneOf
there is a P[number]
.
: Parser<ParserType<P[number]>>
Assuming P
is an indexed type with keys and values whose key type is a number
, this gives the type of the value stored in P
.
So what is P
?
function<P extends Parser<any>[]>oneOf(
P
is an array of Parser<any>[]
. Well it extends Parser<any>[]
.
This is where the magic happens.
TypeScript captures the T
of each Parser<any>
and stores it in P
. Because an Array
is an indexed type whose key is number
, the type of P
can also be expressed like this:
type P = {[number]: (Parser<number>|Parser<string>|Parser<boolean>)};
There it is! The union is the value type at P[number]
.
Putting the Pieces Together
ParserType
is a Conditional Type that given a Parser<T>
, returns T
.
What happens when ParserType
is given a union of Parser<T>
types.
type T = ParserType<(Parser<string> | Parser<number>)>
TypeScript infers the union for T
:
type T = string | number
Given a Mapped Type P
that extends Parser<T>[]
, the union of Parser<T>
types is available at P[number]
.
It follows then that passing the P[number]
into ParserType
will provide the union of T
types in Parser<T>
. That is exactly what the return type in oneOf
does.
Reading the new signature for oneOf
is now less cryptic:
function oneOf<P extends Parser<any>[]>(
...parsers: P
): Parser<ParserType<P[number]>> {
Now to wrap up the implementation.
Using oneOf
doesn’t work unless there is at least one Parser<T>
. The signature can be updated to require one:
function oneOf<T, P extends Parser<any>[]>(
parser: Parser<T>,
...parsers: P
): Parser<T|ParserType<P[number]>> {
// no additional parsers, return the single parser to be used as is
if (parsers.length === 0) {
return parser;
}
return value => mapFailure(
parsers.reduce(
// with each reduction, only, try to parse when the previous was a Failure
(result, next) => mapFailure(result, () => next(value)),
// seed the result with the first parser
parser(value)
),
// if all parsers fail, indicate that there were multiple parsers attempted
() => failure(value, `'${value}' did not match any of ${parsers.length+1} validators`)
);
}
Using oneOf
Using oneOf
now looks like this:
const parseStatus = oneOf(
isExactly('pending'),
isExactly('shipped'),
isExactly('delivered'),
);
This expresses a Parser<T>
that will fail if the string is not 'pending'
, 'shipped'
, or 'delivered'
.
With the new signature of oneOf
, TypeScript now infers parseStatus
to have the type:
const parseStatus: Parser<'pending'|'shipped'|'delivered'>;
Combined with mapSuccess
, the Success<T>
will guarantee that the value is one of those three exact strings.
mapSuccess(parseStatus('other'), status => {
switch(status) {
case 'something': return 'not valid';
}
});
This fails type checking:
Type '"something"' is not comparable to type '"shipped" | "pending" | "delivered"'.
This works with the most complex of Parser<T>
s:
const json: Parser<any> = value = {
try {
return success(JSON.parse(value));
} catch(error) {
return failure(value, error.description);
}
}
const employeesParser = mapParser(json, objectOf({
employees: arrayOf(objectOf({
role: oneOf(
isExactly('Vice President'),
isExactly('Manager'),
isExactly('Individual Contributor')
),
// This one is for you Dennis
// assuming ISO8601 Date strings and a modern browser
hireDate: mapParser(isString, (value) => success(new Date(value)))
}))
})));
mapSuccess(employeesParser("{...JSON HERE...}"), (valid) => {
valid.employees.forEach(employee => {
const employmentDurationInMS = (
Date.now() - employee.hireDate.getTime()
);
switch(employee.role) {
case "Not A Real Role": {
}
}
});
});
The case "Not A Real Role":
doesn’t exist for employee.role
:
Type '"Not A Real Role"' is not comparable to type '"Manager" | "Individual Contributor" | "Vice President"'
Lovely!
Here’s the inferred type of employeesParser
’s use of oneOf
:
function oneOf<"Vice President", [Parser<"Manager">, Parser<"Individual Contributor">]>(parser: Parser<"Vice President">, parsers_0: Parser< "Manager">, parsers_1: Parser<"Individual Contributor">): Parser<...>
We can see where:
- The
Parser<"Manager">
andParser<"Individual Contributor">
types are captured inP
. - The
parsers_0
andparsers_1
are spread as arguments tooneOf
with the correct parser types.