Validate unknown data types using a low overhead syntax.
Don't write an ordinary TypeScript interface:
interface Nested {
e: 'hello'
f: 2
}
interface MyStruct {
a: string;
b: number;
c: Array<boolean>;
d: Nested;
}
Use the type proxies instead:
import { arrayP, numberP, numLiteralP, objectP, stringP, strLiteralP } from 'type-proxy';
const nestedP = objectP({
e: strLiteralP('hello'),
f: numLiteralP(2)
});
const myStructP = objectP({
a: stringP,
b: numberP,
c: arrayP(booleanP),
d: nestedP
});
You get data validation for free. Type proxies can be used in plain JavaScript without any trouble:
// Returns the following:
// {
// success: true,
// value: <Original Data>
// }
myStructP({
a: 'type proxies',
b: 42,
c: [true, false],
d: { e: 'hello', f: 2 },
});
If something goes wrong, you get helpful messages telling you what happened:
// Returns the following:
// {
// success: false,
// error: ParseError
// }
const result = myStructP({
a: 'type proxies',
b: 42,
c: [true, false],
d: { e: 'goodbye', f: 2 },
});
// prints `data.d.e is invalid. We expected "hello" but found "goodbye" instead.`
console.log(result.error.display())
TypeScript users can derive their types from the proxies for free:
import { GetType } from 'type-proxy';
// both of these types are equivalent to MyStruct above
type MyStructType = GetType<typeof myStructP>;
interface MyStructInterface extends GetType<typeof myStructP> {}
The type-proxy
package allows you to derive a validation function for free. At its simplest, a validation function takes some unknown data and returns a value of the type you want to validate:
type Validator<X> = (data: unknown) => X | null;
type-proxy
uses a slightly more complicated validator function that we call TypeProxy
:
export type TypeProxy<T> = (data: unknown) => ParseResult<T>;
export type ParseResult<T> = {
success: true,
value: T
} | {
success: false,
error: ParseError
};
A TypeProxy
is therefore a function that takes some unknown data and validates it. On success, it returns the value. On failure, it returns an error.
While writing web applications, we frequently come across data that is of an unknown type. This is the kind of data that usually comes from a HTTP request, a file, or some other unstructured stream of data. In order to validate that this data is correct, the traditional method is to write a validation function which often looks something like this:
interface MyStruct {
a: number,
b: string
}
const validate = (data: unknown): MyStruct | null => {
if (typeof data !== 'object' || data === null) {
return null;
}
if (typeof (data as { a: unknown}).a !== 'number') {
return null;
}
if (typeof (data as { b: unknown}).b !== 'string') {
return null;
}
return data as MyStruct;
}
This ad hoc strategy is tedious, verbose and error-prone. In TypeScript this often calls for lots of casts, which can often make it relatively unsafe. Instead of writing out the validation function every time, it would be nice to derive the validation function from the interface declaration.
There are many places that we can encounter data of an unknown type:
// JSON.parse
const { body } = await fetch('http://example.com');
const data = JSON.parse(body);
// Event handlers
emitter.on('event', (data) => {
// ...
});
// Poorly typed external APIs
const data = functionThatReturnsAny();
Before you use this library, there are several alternatives that should consider using instead.
These include (with their respective headlines):
- Zod - TypeScript-first schema validation with static type inference.
- Valibot - Validate unknown data with Valibot, the open source schema library with bundle size, type safety and developer experience in mind.
- Yup - Yup is a schema builder for runtime value parsing and validation.
All three are published under the MIT License, and are mature and well tested. Between them they have over 50 thousand stars on GitHub.
Reasons you should NOT use this library:
-
Not Invented Here (NIH): This library was initially developed as a minimal validation interface for use in projects at Vivi. Despite being aware of publicly available alternatives, we decided to release this code as is.
-
Test Coverage: Test coverage is still somewhat lacking, however we hope to improve this... 🙏
-
Completeness: The alternatives listed above are arguably more complete from a type system perspective. They provide well established solutions for parsing and validating complex types.
Why you might want to use this library:
-
Compactness: One of the advantages over alternatives is that
type-proxy
is extremely small, and light-weight. The entire API and implementation can easily be explained in a short how-to session / tutorial. -
Extensibility: The
type-proxy
API has been designed to be easy to extend and compose.
Generic types are supported, but you may need to specify a TypeScript interface for ergonomic reasons. Consider the following types:
type Pair<F, S> = {
first: F,
second: S
};
type Either<L, R> = {
type: 'left',
left: L
} | {
type: 'right',
right: R
};
We can write a type proxy for generic types using functions that take type proxies as parameters:
const pairP = <F, S>(firstP: TypeProxy<F>, secondP: TypeProxy<S>) => objectP({
first: firstP,
second: secondP
});
const eitherP = <L, R>(leftP: TypeProxy<L>, rightP: TypeProxy<R>) => orP(
objectP({
type: strLiteralP('left'),
left: leftP
}),
objectP({
type: strLiteralP('right'),
right: rightP
})
);
The types of the type proxy can be derived as per normal:
const numberStringPairP = pairP(numberP, stringP);
const numberOrStringP = eitherP(numberP, stringP);
type NumberStringPair = GetType<typeof numberStringPairP>;
type NumberOrString = GetType<typeof numberOrStringP>;
It can be shown that these new types are the same as the types we had before:
const assertType = <T>(value: T) => {};
assertType<TypeProxy<Pair<number, string>>>(numberStringPairP);
assertType<TypeProxy<Either<number, string>>>(numberOrStringP);
The only limitation is that we cannot derive generic types from type proxies. For example: we can use GetType
on pairP(numberP, stringP)
to get Pair<number, string>
, but we can not use GetType
on pairP
to get Pair<F, S>
.
When using recursive types using TypeScript, you must specify a TypeScript interface:
type LinkedList = {
value: number,
next: LinkedList
} | null;
You cannot write your type proxy like you would normally, since it will use a variable that has not yet been defined:
const linkedListP = orP(
objectP({
value: numberP,
next: linkedListP // Error: Block-scoped variable 'linkedListP' used before its declaration.
}),
nullP
);
You can however, write out recursive types as a function. Note that for recursive types, the type declaration is required otherwise TypeScript will not be able to resolve the type:
const linkedListP: TypeProxy<LinkedList> = (value: unknown) => orP(
objectP({
value: numberP,
next: linkedListP
}),
nullP
)(value);
const list = { value: 1, next: { value: 2, next: { value: 3, next: null }}};
assert(linkedListP(list).success);
Some experimental support for object transforms has been included.
An example of this is snakeCaseObjectP
. This can be used to parse a payload that contains snake_case
keys, while returning a type that includes those keys as specified in a type proxy. snakeCaseObjectP
is designed to be used in place of objectP
.
Example:
const userP = snakeCaseObjectP({
userName: stringP,
emailAddress: stringP
});
const data = {
user_name: 'Foo',
email_address: 'foo@example.com',
};
const result = userP(data);
assert(result.success);
assert(result.value.userName);
This section is incomplete. Please raise an issue on GitHub if you would like something to be added.
Changes the error message to include a human readable label.
const noLabel = numLiteralP(1809);
assert.equal(noLabel(2022).success, false);
// The following prints:
//
// data is invalid. We expected 1809 but found 2022 instead.
console.log(noLabel(2022).error.display());
const withLabel = labelP('Araham Lincoln\'s birthday', numLiteralP(1809));
assert.equal(withLabel(2022).success, false);
// The following prints:
//
// data is invalid. We expected Araham Lincoln's birthday but found 2022 instead.
// it is not Araham Lincoln's birthday because:
// data is invalid. We expected 1809 but found 2022 instead.
console.log(WithLabel(2022).error.display());
This is useful for when you want more descriptive error messages when describing a union. In the following example, the error message is not very useful:
const unionP = orP(
objectP({
type: strLiteralP('number'),
number: numberP
}),
objectP({
type: strLiteralP('string'),
string: stringP
}),
objectP({
type: strLiteralP('boolean'),
boolean: booleanP
})
);
const result = unionP({ type: 'number', string: 'hello' });
assert.equal(result.success, false);
// prints "data is invalid. We found {"type":"number","string":"hello"}."
console.error(result.error.display());
however, if we add labels to each variant, we get much better error messages:
const unionP = orP(
labelP('a number type', objectP({
type: strLiteralP('number'),
number: numberP
})),
labelP('a string type', objectP({
type: strLiteralP('string'),
string: stringP
})),
labelP(' a boolean type', objectP({
type: strLiteralP('boolean'),
boolean: booleanP
}))
);
const result = unionP({ type: 'number', string: 'hello' });
assert.equal(result.success, false);
// prints the following:
//
// data is invalid. We expected a number type, a string type or a boolean type but found {"type":"number","string":"hello"} instead.
// it is not a number type because:
// data.number is invalid. We expected a number but found undefined instead.
// it is not a string type because:
// data.type is invalid. We expected "string" but found "number" instead.
// it is not a boolean type because:
// data.type is invalid. We expected "boolean" but found "number" instead.
console.error(result.error.display());
Parses a value and returns the result. If the value cannot be parsed, an error is thrown with a description as the error message.
const string = validate('hello', stringP);
assert(string === 'hello');
try {
const string = validate(3, stringP);
assert(false);
} catch (error) {
assert.equal(error.message, 'data is invalid. We expected a string but found 3 instead.');
}
For local development, the library can be built with yarn build
. This will run eslint and run the TypeScript compiler.
The linter can be run independently with yarn lint
, or yarn lint:fix
to automatically fix some errors.
Tests can be run with yarn test
.
This project has been published under the MIT License.
See the LICENSE file for more information.