48
loading...
This website collects cookies to deliver better user experience
typeof
keyof
and as const
. I will not cover it, there are plenty of tutorials on the web already.I
), Output (O
) and Error (E
):import { record, string, union, literal } from '../../lib/nope';
const TodoSchema = record({
id: string(),
content: string(),
status: union([literal('COMPLETE'), literal('INCOMPLETE')]),
});
type TodoInput = typeof TodoSchema['I'];
// this is the static type for the Input of your validate function
/**
{
id: string;
content: string;
status: 'COMPLETE' | 'INCOMPLETE';
}
*/
type Todo = typeof TodoSchema['O'];
// this is the static type for your domain model: Todo
/**
{
id: string;
content: string;
status: 'COMPLETE' | 'INCOMPLETE';
}
*/
type TodoError = typeof TodoSchema['E'];
// this is the static type of an Error for this schema:
/**
{
errors: Array<RecordError>;
properties: {
id: Either<string, StringError>;
content: Either<string, StringError>;
status: Either<'COMPLETE' | 'INCOMPLETE', UnionError>;
}
}
*/
// The signatue of the validate function
type I = typeof TodoSchema['I']; // Input
type O = typeof TodoSchema['O']; // Output
type E = typeof TodoSchema['E']; // Error
type validate = (input: I) => Either<O, E>;
// lets validate some input data:
const either = TodoSchema.validate({
id: '42',
content: 'some content',
status: 'INCOMPLETE',
});
either
. The validate
function does not throw, it returns a Either<Success, Failure>
type. Ok. Now we have seen how we would declare a schema and use it to validate data. In the next section we will have a look on the basic idea and how the string()
schema constructor is implemented.type Success<T> = { status: 'SUCCESS'; value: T };
type Failure<T> = { status: 'FAILURE'; value: T };
type Either<S, F> = Success<S> | Failure<F>;
const success = <T>(v: T): Success<T> => {
return {
status: 'SUCCESS',
value: v,
};
};
const failure = <T>(v: T): Failure<T> => {
return {
status: 'FAILURE',
value: v,
};
};
const isSuccess = <S, F>(either: Either<S, F>): either is Success<S> =>
either.status === 'SUCCESS';
const isFailure = <S, F>(either: Either<S, F>): either is Failure<F> =>
either.status === 'FAILURE';
Success
, Failure
and Either
types and the success
and failure
helpers should be pretty self explanatory. But have you noticed this syntax?(either: Either<S, F>): either is Success<S>
is
syntax. Whaaat? For example, if you call isSuccess
with an Either<Success, Failure>
type, and it returns true, typescript will know that it is of type Success
and in the else block, it will know that it is of type Failure
.const either = TodoSchema.validate({
id: '42',
content: 'some content',
status: 'INCOMPLETE',
});
// typeof either = Either<Todo, TodoError>
if (isSuccess(either)) {
const { value } = either; // value is of type: Todo
} else {
const { value } = either; // value is of type: TodoError
}
string()
schema constructor. Just like the one we have used already for id
and content
of a Todo
:const stringError = (input: unknown) => ({
schema: 'string' as const,
code: 'E_NOT_A_STRING' as const,
message: '',
details: {
provided: {
type: typeof input,
value: input,
},
expected: {
type: 'string',
},
},
});
type StringError = ReturnType<typeof stringError>;
export const string = () => {
const I = null as unknown as string; // type for Input
const O = null as unknown as string; // type for Output
const E = null as unknown as StringError; // type for Error
const validate = (input: typeof I): Either<typeof O, typeof E> =>
typeof input === 'string'
? success(input)
: failure(stringError(input));
return {
schema: 'string' as const,
I,
O,
E,
validate,
};
};
stringError
function returns a error object. its completely up to you how the shape of the error object looks. The most important property is: code
. This should be a unique error code across all of the possible errors, across all of the schemas. Thats why we have labelled it with as const
. This ensures that it is not of type string
but of type E_NOT_A_STRING
. So no other string is assignable to this. It will become handy later to also return a schema
property set to the type: string
via the as const
keyword.string
function returns a object with some properties. Let me explain why we need them. We know already that we will need a validate function that returns a Either<Success, Failure>
type. So in this specific case of a string schema: Either<string, StringError>
. But we will also need 3 types, namely: I
, O
and E
. Those properties are set to null
for all of the schemas, but we manually set it to the Input, Output and Error types of the current schema via the as unknown as SomeType
syntax. This basically tells typescript to shut up because you know what you are doing. This makes it easy to extract the types for Success
and Failure
later on:const schema = string();
type Input = typeof schema['I']; // string
type Output = typeof schema['O']; // string
type Error = typeof schema['E']; // StringError
I
and O
are the same for all of the primitive schemas like: string, number, boolean, date, record, array, ... but they can be different for domain types in your application. Imagine a Email
or a Uuid
type. Both are of the primitive type: string
. But they have some additional validation in order to be a valid email address or uuid. Unfortunately we cannot simply alias it like this: type Email = string
and ensure the validation in the validate function, because typescript has no support for opaque types and will always fall back to string in compiler error messages. I am currently looking into a solution for this and i will maybe write another blog post about this topic 😊.const AddressSchema = record({
street: string(),
zip: string(),
city: string(),
country: union([literal('AT'), literal('DE'), literal('CH')]),
});
const UserSchema = record({
name: string(),
email: string(),
password: string(),
birthday: date(),
newsletter: optional(boolean()),
importedAt: nullable(date()),
address: record({
main: AddressSchema,
others: array(AddressSchema),
}),
profileData: partial(
record({
language: union([literal('DE'), literal('IT'), literal('FR')]),
theme: union([literal('light'), literal('dark')]),
}),
),
});
type User = typeof UserSchema['O'];
/**
{
name: string;
email: string;
password: string;
birthday: Date;
newsletter: boolean | undefined;
importedAt: Date | null;
address: {
main: {
street: string;
zip: string;
city: string;
country: "AT" | "DE" | "CH";
};
others: {
street: string;
zip: string;
city: string;
country: "AT" | "DE" | "CH";
}[];
};
profileData: {
language?: "DE" | "IT" | "FR";
theme?: "light" | "dark";
};
}
*/
type UserError = typeof UserSchema['E'];
/**
{
errors: Array<RecordError>;
properties: {
name: Either<string, StringError>;
email: Either<string, StringError>;
password: Either<string, StringError>;
birthday: Either<Date, DateError>;
newsletter: Either<boolean | undefined, BooleanError>;
importedAt: Either<Date | null, DateError>;
address: {
errors: Array<RecordError>;
properties: {
main: {
errors: Array<RecordError>;
properties: {
street: Either<string, StringError>;
zip: Either<string, StringError>;
city: Either<string, StringError>;
country: Either<"AT" | "DE" | "CH", UnionError>;
}
}
others: {
errors: Array<ArrayErrors>;
items: Array<{
errors: Array<RecordError>;
properties: {
street: Either<string, StringError>;
zip: Either<string, StringError>;
city: Either<string, StringError>;
country: Either<"AT" | "DE" | "CH", UnionError>;
}
}>
}
}
}
profileData: {
errors: Array<RecordError>;
properties: {
language?: Either<"DE" | "IT" | "FR", UnionError>;
theme?: Either<"light" | "dark", UnionError>;
}
}
}
}
*/
code
and is strongly typed. That means you can react to every possible error in a different way at runtime and have a lot of information about every error available.string
, number
, object
or array
for example. Thats enough to extract the static type and ensure the correct type at runtime, but a validation library should be able to validate if:string
schema constructor as an example once again. This is how the stringConstraint
function looks:const stringConstraint = <I extends string, C extends string, T>({
when,
error,
}: {
when: (input: I) => boolean;
error: (input: I) => { code: C; message: string; details?: T };
}) => ({
when,
error: (input: I) => {
const { code, message, details } = error(input);
return {
schema: 'string' as const,
code,
message,
details: {
provided: {
type: typeof input,
value: input,
},
constraint: details,
},
};
},
});
type Constraint = ReturnType<typeof stringConstraint>;
when
function and a error constructor that will be called if your when
function returns true. Now we can use this function to create all sorts of string constraints, like:const minLengthConstraint = (minLength: number) =>
stringConstraint({
when: (input) => input.length < minLength,
error: () => ({
code: 'E_MIN_STRING_LENGTH',
message: 'input does not have the required minimum length',
details: {
expected: {
type: 'string',
minLength,
},
},
}),
});
const emailConstraint = () =>
stringConstraint({
when: (input) =>
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(input),
error: () => ({
code: 'E_NOT_A_EMAIL_ADDRESS',
message: 'it is not a valid email address',
details: {
expected: {
type: 'string',
},
},
}),
});
code
and a message
, a details
object is optional and you are free to put there whatever you want. Now we have some constraints, but what will we do with them? Well, we need to adapt our string
schema constructor function a bit to be able to pass this constraints, so we can call it within the validate function of our schema.export const string = <C extends Constraint>(constraints: Array<C>) => {
const I = null as unknown as string;
const O = null as unknown as string;
const E = null as unknown as Array<StringError | ReturnType<C['error']>>;
const validate = (input: typeof I): Either<typeof O, typeof E> => {
if (typeof input !== 'string') return failure([stringError(input)]);
// this is the new part. mostly
const errors = ((constraints || []) as Array<C>)
.map((c) => (c.when(input) ? c.error(input) : undefined))
.filter(Boolean) as Array<ReturnType<C['error']>>;
return errors.length ? failure(errors) : success(input);
};
return {
schema: 'string' as const,
I,
O,
E,
validate,
};
};
const schema = string([emailConstraint(), minLengthConstraint(8)]);
type ErrorCode = typeof schema['E'][number]['code'];
// "E_MIN_STRING_LENGTH" | "E_NOT_A_EMAIL_ADDRESS" | "E_NOT_A_STRING"