@nxgt/janus-mail
v0.9.7
Published
The e-mails of @nxgt/janus's flows — e-mail verification, password reset, sign-in code, sign-in link, step-up code, password changed, e-mail changed, two-factor enabled and disabled, recovery code used, new sign-in, welcome — rendered in English and Frenc
Maintainers
Readme
@nxgt/janus-mail
The e-mails of @nxgt/janus's
flows, ready to send: e-mail verification, password reset, sign-in code,
sign-in link, a step-up's confirmation code, password changed, e-mail changed, two-factor authentication turned on or
off, a recovery code used, a sign-in from a new device, and a welcome to a new user, in English and
French, with your brand
in them. @nxgt/janus sends no e-mail — its flows answer what to send — and
this package turns that answer into an e-mail and hands it to any
@nxgt/mail transport.
import { createMemoryMailer } from '@nxgt/mail';
import { janusMail } from '@nxgt/janus-mail';
const mail = janusMail({
mailer: createMemoryMailer(), // in production, a transport: @nxgt/mail-smtp, @nxgt/mail-resend…
from: '[email protected]',
brand: 'Acme',
links: {
verifyEmail: (token) => `https://acme.example/verify?token=${token}`,
resetPassword: (token) => `https://acme.example/reset?token=${token}`,
secureAccount: () => 'https://acme.example/account/security',
getStarted: () => 'https://acme.example/',
recoveryCodes: () => 'https://acme.example/account/recovery-codes', // optional
magicLink: (token) => `https://acme.example/sign-in/link?token=${token}`, // optional
},
});
await mail.verifyEmail(await auth.verifyEmail.send(user), { name: user.name, locale: user.locale });The e-mails are built with Maizzle when this
package is built — from
@nxgt/mail-presets,
CSS inlined for mail clients — and shipped as HTML and text. At send time
they are only filled in: your brand, the recipient's name, your links, how
long the link or code lasts, every value escaped. No template engine and no Maizzle run in your server.
0.x. A minor version may still change the surface; the changelog says how.
Install
bun add @nxgt/janus-mail @nxgt/mail @nxgt/janus
bun add -d typescriptThree peers, all required: @nxgt/mail (0.1 or later, below 2 — 1.0 and
its transports included), which defines the Mailer port and the errors;
@nxgt/janus (0.17), whose flows' answers the methods take — types only,
nothing of it is loaded; and typescript (6 or 7). No Maizzle, no Vue, no
Tailwind: they run at this package's build, not in yours.
The @nxgt/mail floor is tested, not claimed: the package's specs and its
typecheck run on @nxgt/mail 0.1.0 as well, in the Floors job, on every CI
run.
It reads its prebuilt e-mails with node:fs: Node (20 or later), Bun or
Deno, not an edge runtime. Rebuilding the e-mails from this repository
needs Node ^22.22.3, ^24.15.0 or >=26, for Maizzle — see
Building; installing the package needs no build. Like @nxgt/janus, it expects "moduleResolution": "bundler".
API
| Export | What it is |
| --- | --- |
| janusMail(options) | The e-mails, over your mailer: verifyEmail, resetPassword, signInCode, magicLink, stepUp, passwordChanged, emailChanged, twoFactorEnabled, twoFactorDisabled, recoveryCodeUsed, newSignIn, welcome, and templates and locales. Options: mailer, from, replyTo?, brand, links (verifyEmail, resetPassword, secureAccount, getStarted, and recoveryCodes? and magicLink?), locales?, fallbackLocale?, templates?, clock?. A wrong option is a bare TypeError, thrown here |
| janusTemplates() | The twelve default templates alone, each (variables & { locale }) => Rendered |
| JanusMail<L>, JanusMailOptions<L> | What janusMail() answers and takes, for the locales L |
| JanusMailTemplate<V, L>, JanusMailTemplates<L>, JanusMailTemplateName | One template, the twelve of them, and their names |
| JanusMailVariables | What each template is given: brand, and name, link, code, expiresIn, newEmail, when, recoveryCodesLeft, or device, time and location, as its e-mail needs |
| JanusMailSendOptions | The third argument of verifyEmail, resetPassword, signInCode, magicLink and stepUp: { expiresIn? }, the expiry as text, over the one derived |
| JanusMailLocale | 'en' \| 'fr': the locales the defaults are built in |
| JanusMailLinks, Recipient | The links option; who an e-mail is for — { name, locale? } |
Each method answers the mailer's SentMail and rejects with the mailer's
MailFailure or MailRefused, untouched: this package defines no error
class. To retry a MailFailure or trace each send, wrap the mailer you pass:
withMailTelemetry(withRetry(mailer), { transport }) — withRetry from
@nxgt/mail 0.8 or later, withMailTelemetry from @nxgt/mail/telemetry
0.9 or later (0.8 names it withTelemetry, which 1.0 removed); see
Sending.
| Method | Sends | To |
| --- | --- | --- |
| verifyEmail(issued, to, options?) | issued from auth.verifyEmail.send(user); a link from links.verifyEmail(issued.token), and how long it lasts | issued.email |
| resetPassword(issued, to, options?) | issued from auth.resetPassword.request(email), checked for null; a link from links.resetPassword(issued.token), and how long it lasts | issued.email |
| signInCode(issued, to?, options?) | issued.code from auth.signInCode.request(email), and how long it lasts — never the challenge | issued.email |
| magicLink(issued, to?, options?) | issued from auth.magicLink.request(email), checked for null; a link from links.magicLink(issued.token), and how long it lasts. A TypeError without links.magicLink | issued.email |
| stepUp(issued, to, options?) | issued.code from auth.stepUp.request(user), narrowed to via: 'email', and how long it lasts, greeting to.name, with links.secureAccount() — never the challenge. A TypeError for via: 'secondFactor' | issued.email |
| passwordChanged(to) | A notice, with links.secureAccount() | to.email |
| emailChanged(to) | A notice naming to.newEmail, with links.secureAccount() | to.formerEmail |
| twoFactorEnabled(to) | A notice: two-factor authentication was turned on, with links.secureAccount() | to.email |
| twoFactorDisabled(to) | A notice: two-factor authentication was turned off, with links.secureAccount() | to.email |
| recoveryCodeUsed(to, { when, recoveryCodesLeft }) | A notice: a recovery code was used, when, and how many are left — "You have 9 recovery codes left." — with links.recoveryCodes(), else links.secureAccount() | to.email |
| newSignIn(to, { device, time, location? }) | A notice: the account was signed in from a new device — which device, time, and location, "Unknown" ("Inconnu") when left out — with links.secureAccount() | to.email |
| welcome(to) | A welcome to a new user — "Welcome, Ada" — with links.getStarted() | to.email |
The five e-mails of a link or a code say how long it lasts — "This link
expires in 1 hour.", "Ce lien expire dans 1 heure." — from the flow's
issued.expiresAt: the time left at send time, rounded to the minute, then
down to the largest whole unit (days, hours or minutes), formatted with
Intl.NumberFormat in the recipient's locale. The time is read from
janusMail({ clock }) — give it the clock you gave janus({ clock }) —
else the system clock. Pass { expiresIn } to say it yourself, as plain
text in the recipient's language.
Usage
The examples use mail from above, and an auth whose user schema has a
name and a locale beside its email — fields of yours, not janus's: pass
whatever your users carry.
Verifying an e-mail
const issued = await auth.verifyEmail.send(user); // { token, email, expiresAt }
await mail.verifyEmail(issued, { name: user.name, locale: user.locale });Resetting a password
const issued = await auth.resetPassword.request(email);
if (issued !== null) {
await mail.resetPassword(issued, { name: issued.user.name, locale: issued.user.locale });
}
return new Response(null, { status: 202 }); // the same answer whether or not someone holds the addressSigning in with a code
import { parseAcceptLanguage } from '@nxgt/mail';
const issued = await auth.signInCode.request(email);
if (issued !== null) {
const wanted = [issued.user.locale, ...parseAcceptLanguage(request.headers.get('accept-language'))];
await mail.signInCode(issued, { locale: wanted });
// issued.challenge stays with the visitor — a cookie or the form — never in the e-mail
}Signing in with a link
const issued = await auth.magicLink.request(email);
if (issued !== null) {
await mail.magicLink(issued, { locale: issued.user.locale }); // "Your sign-in link", valid 15 minutes
}
return new Response(null, { status: 202 }); // the same answer whether or not someone holds the addresslinks.magicLink should open a page of yours that spends nothing, whose
button posts the token to the route calling auth.magicLink.confirm: mail
scanners open every link in an e-mail, and one that signs in on GET signs
in for the scanner. @nxgt/janus's sign-in link guide
has that page.
Confirming a sensitive action — the step-up
const issued = await auth.stepUp.request(current.user); // the user of the session
if (issued.via === 'email') {
await mail.stepUp(issued, { name: current.user.name, locale: current.user.locale }); // "Your confirmation code"
}
return Response.json({ challenge: issued.challenge, via: issued.via }); // the challenge stays with the userA user whose second factor is active confirms with their app
(via: 'secondFactor'): there is nothing to send. The e-mail names no
action, and its Secure my account button links to
links.secureAccount(), for a user who asked for nothing.
@nxgt/janus's step-up guide
has the confirming route.
Saying the expiry yourself
await mail.verifyEmail(issued, { name: user.name, locale: 'fr' }, { expiresIn: '24 heures' });Telling a user their password or e-mail changed
@nxgt/janus 0.14 sends user.passwordChanged once changePassword or
setPassword wrote the password — and 0.16 once signInCode.confirm or
magicLink.confirm dropped a password when proving the e-mail for the
first time, so the inbox's owner hears of a password someone else had set —
and user.emailChanged once update
changed the e-mail. The e-mail change is told to the former address — the
new one belongs to whoever changed it — which the event carries as
formerEmail, null for a user who had none:
const auth = janus({
...config,
async events(event) {
if (event.type === 'user.passwordChanged') {
const user = await auth.get(event.userId);
await mail.passwordChanged({ name: user.name, locale: user.locale, email: user.email });
}
if (event.type === 'user.emailChanged' && event.formerEmail != null) {
const user = await auth.get(event.userId);
await mail.emailChanged({ name: user.name, locale: user.locale, formerEmail: event.formerEmail, newEmail: user.email });
}
},
});A reset sends user.passwordReset, not user.passwordChanged: add that type
to the first test to tell a reset too. The examples assume a required e-mail;
with an optional one, an update that removes it has no newEmail to name —
see Sending.
Telling a user their second factor was turned on or off
@nxgt/janus sends user.secondFactorEnabled once secondFactor.activate
made the factor active, and user.secondFactorDisabled once
secondFactor.disable removed an active one — and, from 0.16, once
signInCode.confirm or magicLink.confirm removed one when proving the
e-mail for the first time, so the inbox's owner hears of a factor someone else
had set — never for a user who had none. The event names the user by id, so
read the rest from the user:
const auth = janus({
...config,
async events(event) {
if (event.type === 'user.secondFactorEnabled' || event.type === 'user.secondFactorDisabled') {
const user = await auth.get(event.userId);
const to = { name: user.name, locale: user.locale, email: user.email };
await (event.type === 'user.secondFactorEnabled' ? mail.twoFactorEnabled(to) : mail.twoFactorDisabled(to));
}
},
});The link is links.secureAccount(), the page where the user manages their
security settings.
Welcoming a new user
@nxgt/janus sends user.created once signUp or create inserted the
user. Read the user by id, and welcome them:
const auth = janus({
...config,
async events(event) {
if (event.type === 'user.created') {
const user = await auth.get(event.userId);
await mail.welcome({ name: user.name, locale: user.locale, email: user.email });
}
},
});The Get started button links to links.getStarted(): your home page,
or your sign-in page for an account someone else created. The welcome goes
out before the address is verified; to welcome proven addresses only, send
it on user.emailVerified instead — see
Sending.
Telling a user a recovery code was used
@nxgt/janus sends user.recoveryCodeUsed once secondFactor.recover
spent a code: a sign-in without the user's phone. The event names the user
by id only, so read the user, and the codes left with
secondFactor.recoveryCodesLeft (@nxgt/janus 0.11):
const auth = janus({
...config,
async events(event) {
if (event.type === 'user.recoveryCodeUsed') {
const user = await auth.get(event.userId);
const recoveryCodesLeft = await auth.secondFactor.recoveryCodesLeft(user);
if (recoveryCodesLeft === null) return; // the factor was turned off since
const when = new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' })
.format(event.occurredAt);
await mail.recoveryCodeUsed({ name: user.name, locale: user.locale, email: user.email }, { when, recoveryCodesLeft });
}
},
});when is text: format it in the recipient's locale and time zone.
recoveryCodesLeft is the count, written as a sentence in the recipient's
locale — "You have 1 recovery code left.", "Il ne vous reste aucun code de
récupération." — or the sentence itself, as text. The button links to
links.recoveryCodes(), where the user regenerates their codes, and to
links.secureAccount() when you give no recoveryCodes.
Telling a user a new device signed in
@nxgt/janus 0.17, given janus({ devices }) and a sign-in's { device },
answers newDevice: true for a device the user had not signed in from, and
sends user.newDeviceSignedIn. Send the notice from the sign-in's answer,
where the request is in hand to name the device:
const signedIn = await auth.signIn({ email, password }, { device: cookie ?? null });
if (signedIn.newDevice) {
const { name, email, locale } = signedIn.user;
const time = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' })
.format(signedIn.session.createdAt);
await mail.newSignIn({ name, email, locale }, { device: 'Firefox on macOS', time }); // "New sign-in to your account"
}or from the event, which names the user by id alone:
const auth = janus({
...config,
async events(event) {
if (event.type === 'user.newDeviceSignedIn') {
const user = await auth.get(event.userId);
const time = new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' })
.format(event.occurredAt);
await mail.newSignIn({ name: user.name, email: user.email, locale: user.locale }, { device: 'a new device', time });
}
},
});Every value is your text, in the recipient's locale: device described
from the request's User-Agent, time formatted in their time zone.
@nxgt/janus sees no IP, so location is yours to work out — from a
geo-IP of your own — and an e-mail without one says "Unknown" in its
place: Location Unknown, Lieu Inconnu in French. The
button links to links.secureAccount().
Replacing a template
Any template can be your own function — React Email, a string, another build — and the defaults stay for the rest:
const mail = janusMail({
mailer, from: '[email protected]', brand: 'Acme', links,
templates: {
signInCode: ({ code, locale }) => ({
subject: locale === 'fr' ? `Votre code : ${code}` : `Your code: ${code}`,
html: `<p>${code}</p>`,
text: code,
}),
},
});A locale beyond en and fr takes every template: see
Templates and Locales.
Traps
Send to issued.email, which the methods do. Never to what the visitor
typed: the flow answers the address the user's record holds, as they
registered it. emailChanged goes to formerEmail — the address a hijacker
just took the account from.
Never fire and forget. void mail.signInCode(issued) turns an outage
into an unhandled rejection and a user waiting for an e-mail that never
comes. await it, or hand it to a queue that retries — which is also how to
send after answering, as the sign-in code guide advises.
A welcome sent from janus({ events }) fails no sign-up. janus()
awaits the listener, but a listener that throws is a JANUS_EVENT_FAILED
warning: the user exists, signUp answers, and a MailFailure is not
thrown to anyone. For a welcome that must arrive, put the event in a queue
that retries, and send from there.
With open sign-up, welcome on user.emailVerified, not user.created.
Whoever signs up picks both the address and the name, and the welcome
writes that name in its subject — "Welcome, " — so on
user.created anyone can make your brand send it to any address. Once the
address is proven, only its owner receives it.
Every link is required, getStarted included — but recoveryCodes.
links written for 0.3 has no getStarted: a compile error on links, and
in JavaScript janusMail: links.getStarted must be a function. Add it even
if you never send the welcome. recoveryCodes is optional: without it,
recoveryCodeUsed links to secureAccount.
The recovery code event carries no count. user.recoveryCodeUsed names
the user only; pass recoveryCodesLeft as
auth.secondFactor.recoveryCodesLeft(user) answers it, once checked for
null — a user whose factor was turned off since has no codes to count, and
null is a compile error here. With a locale the defaults are not built in,
pass the sentence rather than the count: the build has no plural for it, and
a count is a TypeError at send.
node:fs means no edge runtime. The default e-mails are read from the
package's mails/ folder, on the first one sent. On an edge runtime, pass
every template yourself — or wait for the inlined build on the
roadmap.
Keep @nxgt/janus-mail out of your server bundle. The default e-mails
are found beside the code, at new URL('../mails/', import.meta.url).
Bundling the package into your own server file moves import.meta.url to
your bundle, where there is no mails/, and the first e-mail sent throws
createMailRenderer: …/mails/mail-manifest.json cannot be read. Mark it
external — external: ['@nxgt/janus-mail'] in esbuild or Bun.build,
ssr.external in Vite — and deploy node_modules/@nxgt/janus-mail/ whole,
mails/ included. See
troubleshooting.
A link answers a string, at once. Each links function is called at
send time and must return an http(s) or mailto: URL as a string: an
async function or a URL object is a compile error, and in JavaScript a
TypeError naming the call.
links, from and replyTo are copied when janusMail() is called, so
changing them afterwards changes nothing.
links.magicLink is optional, and magicLink refuses to send without
it. An application that sends no sign-in link leaves it out; calling
mail.magicLink then throws a TypeError —
janusMail.magicLink: links.magicLink is missing — … — before anything is
rendered or sent. Unlike recoveryCodes, it has no fallback: no other page
can carry the token.
Leave location out when you do not know it. A geo-IP lookup that found
nothing answers null, which is a compile error here and, in JavaScript,
janusMail.newSignIn: location must be a string: spread it in only when it
is a string — ...(city === null ? {} : { location: city }) — and the
e-mail says "Unknown" in the recipient's locale.
Check via before stepUp. auth.stepUp.request(user) answers
via: 'secondFactor' — no code, no address — for a user whose second
factor is active. Passed un-narrowed it is a compile error; in JavaScript,
janusMail.stepUp: via must be 'email' — …, a TypeError before anything
is sent. Unlike signInCode, stepUp takes the recipient: it greets the
user by name.
Templates are own properties. A class's methods live on its prototype
and are refused with a TypeError; its fields (signInCode = (variables) =>
…) pass.
A locale beyond en and fr needs every template. The defaults are
built in those two only, so locales: ['en', 'fr', 'de'] without all twelve
templates — recoveryCodeUsed included, since 0.5, magicLink, since
0.7, stepUp, since 0.8, and newSignIn, since 0.9 — is a compile error, and a TypeError in JavaScript.
The brand is text only. brand: 'Acme' is written in the header, the
body and the footer, escaped: no logo, no link, no markup. For those, replace
the templates.
Send the flow's answer as it came: expiresAt must be a Date. The
expiry is derived from issued.expiresAt at send time. A flow's answer that
went through JSON — a job queue — holds a string there, which is a compile
error and, in JavaScript, a TypeError (expiresAt must be a Date); revive
it with new Date(...), or pass { expiresIn }. An expiresAt already past
is a TypeError too: the link would not work, so nothing is sent.
Give janusMail() the clock you gave janus(). The time left is
measured against clock, the system clock by default. Tests that run
janus({ clock: fixedClock(...) }) issue an expiresAt in the clock's time:
without the same clock here, a clock set in the past makes every send an
expiresAt is past TypeError, and one set ahead a wrong duration.
Locales are BCP 47 tags: fr-CA, never fr_CA. The expiry is
formatted by Intl in the locale picked, so a locale Intl refuses is a
TypeError from janusMail():
janusMail: locales must be BCP 47 language tags, as 'fr-CA'.
The expiry is formatted by the runtime's Intl. A locale the runtime has
no data for is formatted in its default language, and French puts a no-break
space between the number and some units, as CLDR says. For a wording of your
own, pass { expiresIn }.
A failed send is MAIL_FAILED; answer it as an outage. In an HTTP
handler, a MailFailure is a 503 — nothing is known to have been sent —
and a MailRefused (a javascript: link, an address that is not one) is a
bug to fix, never retried:
import { MailError } from '@nxgt/mail';
try {
await mail.verifyEmail(await auth.verifyEmail.send(user), { name: user.name });
} catch (error) {
if (error instanceof MailError && error.code === 'MAIL_FAILED') {
return Response.json({ code: error.code }, { status: 503 });
}
throw error;
}The symptoms and fixes are in troubleshooting.
Documentation
- Guides — sending, templates, locales and how the e-mails are built
- Troubleshooting — every message this package throws, and
MailRefused - Roadmap — what is next, and what is not planned
Type safety, counted
Fifty plausible mistakes, fifty refused at compile time.
Nine files hold one @ts-expect-error per mistake, beside the calls that
must keep compiling:
test/types/send-refusals.ts the eight of a
send, 1 to 8;
test/types/option-refusals.ts the twelve
of janusMail()'s options, 9 to 20, beside adding a language with every
template;
test/types/expiry-refusals.ts the five of
the expiry and the clock, 21 to 25;
test/types/notice-refusals.ts the four of
the two-factor notices and the welcome, 26 to 29;
test/types/recovery-refusals.ts the
five of the recovery code notice, 30 to 34;
test/types/change-refusals.ts the two of
the e-mail change notice sent on its event, 35 and 36;
test/types/magic-link-refusals.ts the
four of the sign-in link, 37 to 40;
test/types/step-up-refusals.ts the six
of the step-up's code, 41 to 46, beside the names of the templates, held to
the twelve; and
test/types/new-sign-in-refusals.ts
the four of the new sign-in notice, 47 to 50:
- A sign-in code given to
verifyEmail: it has no token. - A one-time token given to
signInCode: it has no code. resetPassword.request's answer, not checked fornullfirst.verifyEmailwithout the recipient.- A recipient without a name.
passwordChangedwithout the address to tell.emailChangedwithout the former address.- A locale that is not a locale (a number).
linkswithoutsecureAccount.- A link given as a URL rather than a function of the token.
brandgiven as an object ({ name: 'Acme' }) rather than its name.- A template that answers a string rather than
{ subject, html, text }. - A template that reads a variable its e-mail does not have.
- A template for an e-mail the package does not send.
- A
fallbackLocaleoutsidelocales. - A locale beyond
enandfrwith only some templates. - A default template reused for a locale it is not built in.
- A template read from
mail.templatesthat does not exist. - A link computed asynchronously.
- A link answered as a
URLobject rather than itshref. - A sign-in code without its
expiresAt. - A flow's answer that went through JSON:
expiresAta string. expiresIngiven as a number of seconds rather than the text to show.expiresIngiven with the recipient rather than as the send's option.clockgiven as a function rather than@nxgt/janus'sClock.- The user event itself given to
twoFactorDisabled: it has neither a name nor an address. twoFactorEnabledgivenemailChanged'sformerEmailrather thanemail.- The
user.createdevent itself given towelcome: it has neither a name nor an address. linkswritten before the welcome, withoutgetStarted.- The
user.recoveryCodeUsedevent itself given torecoveryCodeUsed: it has neither a name nor an address. recoveryCodeUsedwithoutwhenand the count.- The count as
recoveryCodesLeft()answered it, not checked fornullfirst. whengiven as the event'sDaterather than the text in the recipient's locale and time zone.links.recoveryCodesgiven as a URL rather than a function.user.emailChanged'sformerEmailgiven toemailChangedunchecked: it isnullfor a user who had none.- The
user.emailChangedevent itself given toemailChanged: it has the former address, but neither a name nor the new one. - A sign-in code given to
magicLink: it has no token, and its challenge must never be mailed. magicLink.request's answer, not checked fornullfirst.- The token alone given to
magicLink, rather than what the flow answered. links.magicLinkcomputed asynchronously.stepUp.request's answer not narrowed tovia: 'email': an app's step-up has nothing to send.- A step-up confirmed with the user's app, narrowed to it, given to
stepUp. - A sign-in code given to
stepUp: it signs in, it confirms no action. - A one-time token given to
stepUp: it has no code. stepUpwithout the recipient: its e-mail greets the user by name.- The step-up's
usergiven as the recipient when the user type has no name. - The
user.newDeviceSignedInevent itself given tonewSignIn: it has neither a name nor an address. newSignInwithout the device and the time.timegiven as the session'sDaterather than the text in the recipient's locale and time zone.- A location lookup's
nullpassed aslocation, rather than leaving it out.
In JavaScript, 19 to 23, 26 to 28, 30 to 33 and 35 to 50 are a TypeError at send
time instead, naming the call or the field, and 25, 29 and 34 one from
janusMail().
test/types/variables.ts also holds
JanusMailVariables equal to the variables of the build: an e-mail that
gains or loses one fails the typecheck.
Licence
MIT
