What's new in Evolu Type
Evolu Type is a TypeScript library for validating data at runtime. It's part of Evolu Library, the library Evolu is built on. Evolu uses it everywhere, and your app can use it with or without the rest of Evolu.
We made it because we care about correctness and developer experience. A local-first app keeps its users' data on their devices, where its developers can't inspect or repair it, so invalid data must not get in. And the correct code should be the easiest code to write.
If you're new to Evolu Type, read on. If you already use it, skip to what's new in 8.20: redacted secrets, form errors at the right field, and many new Types.
What makes Evolu Type different
A Type validates an unknown value and returns a Result: the typed value, or a
typed error.
import {
assertEqual,
assertErr,
assertOk,
brand,
lessThan,
NonNegativeInt,
} from "@evolu/common";
const Age = brand("Age", lessThan(200)(NonNegativeInt));
assertOk(Age.fromUnknown(37), 37);
const age = Age.fromUnknown(37.5);
assertErr(age);
assertEqual(age.error, { type: "Int", value: 37.5 });
assertEqual(
Age.formatError(age.error),
"The value 37.5 must be a safe integer.",
);
Age ships with Evolu, defined exactly like this.
Errors are data
An error is a plain object, such as { type: "Int", value: 37.5 }. It isn't an
Error instance, and it carries no message. Its TypeScript type lists exactly
the errors that Type can return, so you can switch on it and format it later,
in any language. When the input is plain data, so is the error, so you can also
store it or send it to a worker.
Most validation libraries don't work this way. Zod 4 returns a ZodError, an
Error instance whose issues are one generic union for all schemas, each with a
prebuilt message. Effect Schema 4 also formats messages later, but its Schema
decoders fail with a SchemaError, also an Error, and its issues are one
union of class instances that hold schema AST nodes, so they can't be sent to a
worker as they are. Valibot comes closest: its issues are plain objects typed
per schema, but each still carries a prebuilt message.
Constraints stay in the type
Every constraint a Type checks shows in its type. Age is a number that passed
six checks, and its type says so, so a function can ask for a value that has
been validated:
import { Age, assertType, type Brand } from "@evolu/common";
assertType<
Age,
number &
Brand<"NonNaN"> &
Brand<"Finite"> &
Brand<"Int"> &
Brand<"NonNegative"> &
Brand<"LessThan200"> &
Brand<"Age">
>();
const canVote = (age: Age): boolean => age >= 18;
// @ts-expect-error A number is not an Age until Age validates it.
canVote(37);
Zod, Valibot, and Effect Schema let you opt into a brand, but their built-in
constraints don't add one. z.string().min(1).max(100) still outputs string,
so the compiler forgets what was checked.
Translations checked by the compiler
formatError turns an error into an English message. localizeTypes returns
the same Types with messages in another language, and @evolu/common/intl
translates every message into 43 locales. The formatters a Type needs are
inferred from the Type, so a missing translation is a compile error, not an
English or generic message at runtime.
import { Age, assertEqual, assertErr, localizeTypes } from "@evolu/common";
import { cs } from "@evolu/common/intl";
const typesByLocale = localizeTypes(
{ Age },
{
cs: {
Number: cs.formatNumberError,
NonNaN: cs.formatNonNaNError,
Finite: cs.formatFiniteError,
Int: cs.formatIntError,
NonNegative: cs.formatNonNegativeError,
LessThan200: cs.formatLessThanError,
},
},
);
const age = typesByLocale.cs.Age.fromUnknown(37.5);
assertErr(age);
assertEqual(
typesByLocale.cs.Age.formatError(age.error),
"Hodnota 37.5 musí být bezpečné celé číslo.",
);
localizeTypes(
{ Age },
// @ts-expect-error localizeTypes requires a formatter for every error Age can return.
{ cs: { Int: cs.formatIntError } },
);
Strict by default
Objects are exact: a property the Type doesn't declare is an error, not something silently dropped. Validation reads property descriptors and never calls a getter, so a getter fails validation instead of running.
Codecs and Standard Schema
Like Effect Schema and Zod's codecs, Types are codecs: IntFromString decodes
"42" to 42 and encodes it back. Every valid value encodes to one canonical
input.
Like Zod and Valibot, every Type implements Standard Schema, so form libraries and other tools that accept Standard Schema accept Evolu Types too.
To try it, install @evolu/common. It needs TypeScript 7 or newer with
exactOptionalPropertyTypes, and the
Type reference documents every Type.
What's new in 8.20
Redacted secrets
Mnemonic and SimplePassword errors used to contain the rejected input, and
their messages printed it. A single mistyped word exposed the rest of a
mnemonic. Now every string they reject fails with an error that doesn't contain
it.
The new redacted validates a secret with an inner Type and wraps the result
in Redacted, so a decoded password, mnemonic, or API key doesn't leak through
logging or serialization. A non-string fails with a Redacted error that
doesn't contain it either.
import {
assertEqual,
assertOk,
object,
redacted,
revealRedacted,
SimplePassword,
String,
} from "@evolu/common";
const SignIn = object({ email: String, password: redacted(SimplePassword) });
const signIn = SignIn.fromUnknown({
email: "ada@example.com",
password: "correct horse",
});
assertOk(signIn);
assertEqual(revealRedacted(signIn.value.password), "correct horse");
assertEqual(
JSON.stringify(signIn.value),
'{"email":"ada@example.com","password":"<redacted>"}',
);
The inner Type's errors pass through unchanged, so they must not keep the
value. Use SimplePassword, Mnemonic, or your own Type with value-free
errors. The compiler rejects inner errors with a value property, so
redacted(minLength(8)(String)) doesn't compile.
In a union, the other members' errors keep the raw input, so use optional for
an absent secret, not nullOr. Two Redacted values are equal only when they
are the same wrapper, and a Redacted has no hash, because a hash of a short
secret such as a PIN can be brute-forced.
Form errors at the right field
A rule spanning several fields, such as a range whose maximum must not be below
its minimum, is a refinement of the whole object. Its error used to be reported
at the object itself, so a form couldn't show it at a field. Now a brand or a
child createType can say where its error belongs:
import {
assertEqual,
assertErr,
brand,
err,
FiniteNumber,
object,
ok,
typeErrorToIssues,
type TypeError,
} from "@evolu/common";
interface PriceRangeError extends TypeError<"PriceRange"> {}
const PriceRange = brand(
"PriceRange",
object({ min: FiniteNumber, max: FiniteNumber }),
(value) =>
value.min <= value.max
? ok()
: err<PriceRangeError>({ type: "PriceRange" }),
() => "The maximum must not be below the minimum.",
{ path: ["max"] },
);
const result = PriceRange.fromUnknown({ min: 20, max: 10 });
assertErr(result, { type: "PriceRange" });
assertEqual(typeErrorToIssues(PriceRange, result.error), [
{ path: ["max"], message: "The maximum must not be below the minimum." },
]);
The error itself is unchanged, the path also reaches Standard Schema, and a path that doesn't exist in the object doesn't compile.
New Types
The text formats accept one canonical spelling and don't normalize input. Types
named XFromY, such as IpAddressFromString and DateIsoFromRfc3339, convert.
- Text formats:
Base64,Hex,UuidV4,UuidV7,uuidVersion,Ulid,Hostname,Ipv4Address,Ipv6Address,IpAddress,PhoneNumberE164,Iban,Isbn, andHexColor.Ipv6AddressFromStringandIpAddressFromStringconvert IPv6 address text to its canonical form. - Bytes:
Uint8ArrayFromBase64,Uint8ArrayFromBase64Url, andUint8ArrayFromHexdecode text to bytes and encode it back. - Dates:
PlainDateIsoaccepts a calendar date such as2024-02-29and rejects one that doesn't exist.ValidDaterejects an Invalid Date, andDateIsoFromRfc3339decodes RFC 3339 date-times. - Numbers:
FiniteNumberFromString,Int32,UInt32, and bounds for bigints, fromgreaterThanBigInttobetweenBigInt. - Strings:
endsWith,includes,excludes,maxUtf8ByteLength,wellFormed, which rejects lone surrogates, andnormalized, which requires a Unicode normalization form. - Collections:
unique,minEntriesandmaxEntriesfor records and objects,minSizeandmaxSizefor Sets, Maps, and Blobs, andnonEmptyArray.
import {
assertErr,
assertOk,
array,
Iban,
IpAddressFromString,
PlainDateIso,
String,
unique,
} from "@evolu/common";
assertOk(PlainDateIso.fromUnknown("2024-02-29"));
assertErr(PlainDateIso.fromUnknown("2023-02-29"));
assertOk(Iban.fromUnknown("GB82WEST12345698765432"));
assertOk(IpAddressFromString.fromUnknown("2001:0DB8::1"), "2001:db8::1");
assertErr(unique(array(String)).fromUnknown(["a", "b", "a"]));
Counts checked at compile time
minLength(-1) used to accept every value, and maxLength(1.5) behaved as
maxLength(1). Length, size, and entry-count factories now take only a
non-negative integer:
import { maxLength, minLength, String } from "@evolu/common";
minLength(1)(String);
// @ts-expect-error Count must be a non-negative integer.
minLength(-1)(String);
// @ts-expect-error Count must be a non-negative integer.
maxLength(1.5)(String);
Better messages
We reviewed every message in every locale. The review fixed wording, grammar,
and terminology in 37 locales, and messages no longer break for counts such as
"exactly 1 elements". Every locale also translates the errors of Data and the
byte length Types now.
Upgrading
Most changes are additions. Three can affect existing code:
MnemonicErrorno longer hasvalue, andSimplePasswordfails with aSimplePasswordErrorwhosereasonis"Untrimmed","TooLong", or"TooShort". InlocalizeTypes, these Types take theStringformatter andformatMnemonicErrororformatSimplePasswordErrorinstead of theTrimmed,MinLength, andMaxLengthformatters.Mnemonicaccepts only lowercase ASCII words separated by single spaces. Convert other input to NFKD and single spaces before validating it.- A negative or fractional count passed to
minLength,maxLength,length, or the other count factories no longer compiles.
The changelog lists every change.