Initial commit
This commit is contained in:
commit
1baf20f6c0
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
24
LICENSE
Normal file
24
LICENSE
Normal file
|
@ -0,0 +1,24 @@
|
|||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
157
README.md
Normal file
157
README.md
Normal file
|
@ -0,0 +1,157 @@
|
|||
# Parsec
|
||||
|
||||
A tiny (<1kb gzipped) library for parsing and validating data in TypeScript.
|
||||
|
||||
For more comprehensive type modeling, consider [zod](https://zod.dev)
|
||||
|
||||
## Install
|
||||
|
||||
Configure your environment to be aware of @endeavorance packages:
|
||||
|
||||
```toml
|
||||
# Bunfig.toml
|
||||
[install.scopes]
|
||||
endeavorance = "https://git.astral.camp/api/packages/endeavorance/npm/"
|
||||
```
|
||||
|
||||
Then install the package:
|
||||
|
||||
```sh
|
||||
bun add @endeavorance/parsec
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Parsec exports the `Parse` constant which contains a set of functions for
|
||||
parsing and validating data.
|
||||
|
||||
### Primitives
|
||||
|
||||
These functions can be invoked directly, passing in the value to parse.
|
||||
|
||||
- `Parse.unknown(value: unknown): unknown`
|
||||
- `Parse.string(value: unknown): string`
|
||||
- `Parse.number(value: unknown): number`
|
||||
- `Parse.boolean(value: unknown): boolean`
|
||||
- `Parse.null(value: unknown): null`
|
||||
- `Parse.undefined(value: unknown): undefined`
|
||||
- `Parse.bigint(value: unknown): bigint`
|
||||
- `Parse.symbol(value: unknown): symbol`
|
||||
|
||||
|
||||
```ts
|
||||
const num = Parse.number(42);
|
||||
const str = Parse.string("hello");
|
||||
const wontParse = Parse.number("hello"); // throws ParseError
|
||||
```
|
||||
### Primitive Values
|
||||
|
||||
These functions can be invoked directly and parse primitive values with
|
||||
additional constraints or specific meaning.
|
||||
|
||||
- `Parse.int(value: unknown): number`
|
||||
- `Parse.nan(value: unknown): number`
|
||||
|
||||
```ts
|
||||
const anInteger = Parse.int(42);
|
||||
const willThrow = Parse.int(42.5); // throws ParseError
|
||||
|
||||
const itsNan = Parse.nan(NaN);
|
||||
const itsNotNan = Parse.nan(42); // throws ParseError
|
||||
```
|
||||
|
||||
### Parser Creators
|
||||
|
||||
These functions return a new parser function that can be used to parse values.
|
||||
|
||||
These functions each take an optional second parameter: a human-readable string
|
||||
identifying the shape that this parser is for. This is useful for debugging but is not
|
||||
required and will default to an generic version of the parser.
|
||||
|
||||
- `Parse.arrayOf<T>(parser: Parser<T>): (val: unknown) => T[]`
|
||||
- `Parse.shape<T>(shape: T): (val: unknown) => T`
|
||||
- `Parse.enum<E extends string>(values: readonly E[]): (val: unknown) => E`
|
||||
- `Parse.regex(regex: RegExp): (val: unknown) => string`
|
||||
- `Parse.literal(literal: unknown): (val: unknown) => literal`
|
||||
- `Parse.instanceOf<T>(classDef: Class): (val: unknown) => T`
|
||||
|
||||
```ts
|
||||
// An array of strings
|
||||
const arrayOfStrings = Parse.arrayOf(Parse.string)(["hello", "world"]);
|
||||
|
||||
// A defined object shape
|
||||
const personParser = Parse.shape({
|
||||
name: Parse.string,
|
||||
age: Parse.number,
|
||||
isCool: Parse.boolean,
|
||||
}, "Person");
|
||||
|
||||
const person = personParser({
|
||||
name: "Alice",
|
||||
age: 42,
|
||||
isCool: true,
|
||||
});
|
||||
|
||||
// An element of an enum
|
||||
const MOODS = ["happy", "sad", "angry"] as const;
|
||||
const moodParser = Parse.enum(MOODS);
|
||||
const mood = moodParser("happy");
|
||||
|
||||
// A string that matches a regex
|
||||
const allCaps = Parse.regex(/^[A-Z]+$/)("HELLO");
|
||||
|
||||
// A literal value
|
||||
const parseHello = Parse.literal("hello");
|
||||
const parsedHello = parseHello("hello");
|
||||
const wontWork = parseHello("world"); // throws ParseError
|
||||
|
||||
// An instance
|
||||
const parseDate = Parse.instanceOf(Date)(new Date());
|
||||
```
|
||||
### Modifiers
|
||||
|
||||
Use `Parse.nullable()` and `Parse.optional()` to wrap parsers to allow for `null` or `undefined` respectively:
|
||||
|
||||
```ts
|
||||
const numberOrNull = Parse.nullable(Parse.number);
|
||||
const optionalArrayOfStrings = Parse.optional(Parse.arrayOf(Parse.string)));
|
||||
```
|
||||
|
||||
### Extracting Types from Parsers
|
||||
|
||||
When composing parsers to create more complex types, you can use the `ReturnType`
|
||||
type helper:
|
||||
|
||||
```ts
|
||||
const parsePerson = Parse.shape({
|
||||
name: Parse.string,
|
||||
age: Parse.number,
|
||||
isCool: Parse.boolean,
|
||||
});
|
||||
|
||||
type Person = ReturnType<typeof parsePerson>;
|
||||
```
|
||||
## Errors
|
||||
|
||||
Parsec will throw `ParseError` errors when parsing fails.
|
||||
|
||||
`ParseError` objects have a string `shape` property which contains the
|
||||
expected type of the value.
|
||||
|
||||
## Branding
|
||||
|
||||
Parsec exports a helper type, `Brand<T, B>` which brands type T with the
|
||||
label provided in B. This is useful for creating custom types that are
|
||||
primitive types under the hood but have a different name.
|
||||
|
||||
```ts
|
||||
import type { Brand } from "@endeavorance/parsec";
|
||||
type NegativeInteger = Brand<number, "NegativeInteger">;
|
||||
function parseInteger(val: unknown): Integer {
|
||||
if (typeof val !== "number" || !Number.isInteger(val) || val > 0) {
|
||||
throw new ParseTypeMismatch("Integer", val);
|
||||
}
|
||||
|
||||
return val as NegativeInteger;
|
||||
}
|
||||
```
|
31
biome.json
Normal file
31
biome.json
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": ["dist"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
46
bun.lock
Normal file
46
bun.lock
Normal file
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "parsit",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.6", "", { "dependencies": { "bun-types": "1.2.6" } }, "sha512-fY9CAmTdJH1Llx7rugB0FpgWK2RKuHCs3g2cFDYXUutIy1QGiPQxKkGY8owhfZ4MXWNfxwIbQLChgH5gDsY7vw=="],
|
||||
|
||||
"@types/node": ["@types/node@22.13.13", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.6", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-FbCKyr5KDiPULUzN/nm5oqQs9nXCHD8dVc64BArxJadCvbNzAI6lUWGh9fSJZWeDIRD38ikceBU8Kj/Uh+53oQ=="],
|
||||
|
||||
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
|
||||
|
||||
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
}
|
||||
}
|
29
package.json
Normal file
29
package.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@endeavorance/parsec",
|
||||
"version": "0.3.0",
|
||||
"author": "Endeavorance",
|
||||
"type": "module",
|
||||
"module": "dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/index.js",
|
||||
"dist/index.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"fmt": "biome check --fix",
|
||||
"build": "bun run clean && bun build ./src/index.ts --minify --outfile ./dist/index.js && bun --bunx tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
246
src/index.ts
Normal file
246
src/index.ts
Normal file
|
@ -0,0 +1,246 @@
|
|||
declare const __parsed_brand: unique symbol;
|
||||
export type Brand<T, B> = T & { [__parsed_brand]: B };
|
||||
export type Parser<T> = (value: unknown) => T;
|
||||
|
||||
export class ParseError extends Error {
|
||||
name = "ParseError";
|
||||
shape: string;
|
||||
path: string;
|
||||
|
||||
constructor(message: string, shape = "unknown", path = "") {
|
||||
super(message);
|
||||
this.shape = shape;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
const MismatchError = (expectedType: string, receivedValue: unknown) => {
|
||||
return new ParseError(
|
||||
`Expected: ${expectedType}, Received: ${typeof receivedValue} ${JSON.stringify(receivedValue)}`,
|
||||
expectedType,
|
||||
);
|
||||
};
|
||||
|
||||
type TypeofType = "number" | "string" | "boolean" | "bigint" | "symbol";
|
||||
const assertTypeof = <T>(val: unknown, expectedType: TypeofType): T => {
|
||||
// biome-ignore lint/suspicious/useValidTypeof: Safe through types
|
||||
if (typeof val !== expectedType) {
|
||||
throw MismatchError(expectedType, val);
|
||||
}
|
||||
|
||||
return val as T;
|
||||
};
|
||||
|
||||
export const Parse = {
|
||||
unknown(val: unknown): unknown {
|
||||
return val;
|
||||
},
|
||||
|
||||
number(val: unknown): number {
|
||||
return assertTypeof<number>(val, "number");
|
||||
},
|
||||
|
||||
string(val: unknown): string {
|
||||
return assertTypeof<string>(val, "string");
|
||||
},
|
||||
|
||||
boolean(val: unknown): boolean {
|
||||
return assertTypeof<boolean>(val, "boolean");
|
||||
},
|
||||
|
||||
bigint(val: unknown): bigint {
|
||||
return assertTypeof<bigint>(val, "bigint");
|
||||
},
|
||||
|
||||
symbol(val: unknown): symbol {
|
||||
return assertTypeof<symbol>(val, "symbol");
|
||||
},
|
||||
|
||||
null(val: unknown): null {
|
||||
if (val === null) {
|
||||
return val;
|
||||
}
|
||||
|
||||
throw MismatchError("null", val);
|
||||
},
|
||||
|
||||
undefined(val: unknown): undefined {
|
||||
if (val === undefined) {
|
||||
return val;
|
||||
}
|
||||
|
||||
throw MismatchError("undefined", val);
|
||||
},
|
||||
|
||||
literal<
|
||||
T extends string | number | boolean | bigint | symbol | null | undefined,
|
||||
>(literal: T): (val: unknown) => T {
|
||||
return (val: unknown) => {
|
||||
if (val === literal) {
|
||||
return val as T;
|
||||
}
|
||||
|
||||
throw MismatchError(String(literal), val);
|
||||
};
|
||||
},
|
||||
|
||||
int(val: unknown): number {
|
||||
const asNum = Parse.number(val);
|
||||
|
||||
if (!Number.isInteger(asNum)) {
|
||||
throw MismatchError("integer", asNum);
|
||||
}
|
||||
|
||||
return asNum;
|
||||
},
|
||||
|
||||
nan(val: unknown): number {
|
||||
if (typeof val === "number" && Number.isNaN(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
throw MismatchError("NaN", val);
|
||||
},
|
||||
|
||||
instanceOf<T>(classDef: { new(): T }): (val: unknown) => T {
|
||||
return (val: unknown) => {
|
||||
if (val instanceof classDef) {
|
||||
return val;
|
||||
}
|
||||
|
||||
throw MismatchError(`instanceof ${classDef.name}`, val);
|
||||
};
|
||||
},
|
||||
|
||||
arrayOf<T>(
|
||||
parser: (val: unknown) => T,
|
||||
shapeName = "Array",
|
||||
): (val: unknown) => T[] {
|
||||
return (val: unknown) => {
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((entry, index) => {
|
||||
try {
|
||||
return parser(entry);
|
||||
} catch (error) {
|
||||
if (error instanceof ParseError) {
|
||||
error.path += `${shapeName}[${index}]`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw MismatchError(shapeName, val);
|
||||
};
|
||||
},
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Could be fixed; intentionally kept simpler
|
||||
shape<T extends Record<string, Parser<any>>>(
|
||||
parsers: T,
|
||||
shapeName = "Shape",
|
||||
): (val: unknown) => { [K in keyof T]: ReturnType<T[K]> } {
|
||||
return (val: unknown) => {
|
||||
if (val === null || typeof val !== "object") {
|
||||
throw MismatchError("object", val);
|
||||
}
|
||||
|
||||
const result = {} as { [K in keyof T]: ReturnType<T[K]> };
|
||||
|
||||
for (const key in parsers) {
|
||||
if (!(key in val)) {
|
||||
throw new ParseError(`Missing prop: ${key}`, shapeName);
|
||||
}
|
||||
|
||||
const value = (val as Record<string, unknown>)[key];
|
||||
|
||||
try {
|
||||
// biome-ignore lint/style/noNonNullAssertion: Logically guaranteed
|
||||
result[key] = parsers[key]!(value);
|
||||
} catch (error) {
|
||||
if (error instanceof ParseError) {
|
||||
error.path += `${shapeName}.${key}`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
},
|
||||
|
||||
regex<T extends string>(
|
||||
regex: RegExp,
|
||||
shapeName = "Regex String",
|
||||
): (val: unknown) => T {
|
||||
return (val: unknown) => {
|
||||
const asStr = Parse.string(val);
|
||||
if (regex.test(asStr)) {
|
||||
return val as T;
|
||||
}
|
||||
|
||||
throw MismatchError(`${shapeName} (${regex.toString()})`, asStr);
|
||||
};
|
||||
},
|
||||
|
||||
enum<E extends string>(
|
||||
enumDef: readonly E[],
|
||||
shapeName = "Enum",
|
||||
): (val: unknown) => E {
|
||||
return (val: unknown) => {
|
||||
if (typeof val !== "string") {
|
||||
throw MismatchError("string", val);
|
||||
}
|
||||
|
||||
if (!enumDef.includes(val as E)) {
|
||||
throw MismatchError(shapeName, val);
|
||||
}
|
||||
|
||||
return val as E;
|
||||
};
|
||||
},
|
||||
|
||||
optional<T>(parser: (val: unknown) => T): (val: unknown) => T | undefined {
|
||||
return (val: unknown) => {
|
||||
if (val === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parser(val);
|
||||
};
|
||||
},
|
||||
|
||||
nullable<T>(parser: (val: unknown) => T): (val: unknown) => T | null {
|
||||
return (val: unknown) => {
|
||||
if (val === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parser(val);
|
||||
};
|
||||
},
|
||||
|
||||
oneOf<T extends Parser<unknown>[]>(
|
||||
parsers: T,
|
||||
shapeName = "Union",
|
||||
): (value: unknown) => ReturnType<T[number]> {
|
||||
return (value: unknown): ReturnType<T[number]> => {
|
||||
const errors: Error[] = [];
|
||||
|
||||
for (const parser of parsers) {
|
||||
try {
|
||||
return parser(value) as ReturnType<T[number]>;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw MismatchError(shapeName, value);
|
||||
};
|
||||
},
|
||||
} as const;
|
228
src/test/parse.test.ts
Normal file
228
src/test/parse.test.ts
Normal file
|
@ -0,0 +1,228 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { Parse, ParseError } from "../index.ts";
|
||||
|
||||
describe(Parse.unknown, () => {
|
||||
test("returns the value as provided", () => {
|
||||
const val = Parse.unknown("hello");
|
||||
expect(val).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.string, () => {
|
||||
test("Parses strings", () => {
|
||||
expect(Parse.string("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-string", () => {
|
||||
const INVALID_VALUES = [null, undefined, 123, true, false, {}];
|
||||
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.string(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.number, () => {
|
||||
test("Parses numbers", () => {
|
||||
expect(Parse.number(123)).toBe(123);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-number", () => {
|
||||
const INVALID_VALUES = [null, undefined, "hello", true, false, {}];
|
||||
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.number(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.boolean, () => {
|
||||
test("Parses booleans", () => {
|
||||
expect(Parse.boolean(true)).toBe(true);
|
||||
expect(Parse.boolean(false)).toBe(false);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-boolean", () => {
|
||||
const INVALID_VALUES = [null, undefined, "hello", 123, {}];
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.boolean(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.bigint, () => {
|
||||
test("Parses bigints", () => {
|
||||
expect(Parse.bigint(BigInt(123))).toBe(BigInt(123));
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-bigint", () => {
|
||||
const INVALID_VALUES = [null, undefined, "hello", 123, true, false, {}];
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.bigint(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.symbol, () => {
|
||||
test("Parses symbols", () => {
|
||||
const symbol = Symbol("hello");
|
||||
expect(Parse.symbol(symbol)).toBe(symbol);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-symbol", () => {
|
||||
const INVALID_VALUES = [null, undefined, "hello", 123, true, false, {}];
|
||||
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.symbol(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.null, () => {
|
||||
test("Parses null", () => {
|
||||
expect(Parse.null(null)).toBe(null);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-null", () => {
|
||||
const INVALID_VALUES = [undefined, "hello", 123, true, false, {}];
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.null(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.undefined, () => {
|
||||
test("Parses undefined", () => {
|
||||
expect(Parse.undefined(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-undefined", () => {
|
||||
const INVALID_VALUES = [null, "hello", 123, true, false, {}];
|
||||
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.undefined(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.arrayOf, () => {
|
||||
test("Parses arrays of a specific type", () => {
|
||||
const arr = [1, 2, 3];
|
||||
expect(Parse.arrayOf(Parse.number)(arr)).toEqual(arr);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-array", () => {
|
||||
expect(() => Parse.arrayOf(Parse.number)("hello")).toThrow(ParseError);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing an array with invalid values", () => {
|
||||
const INVALID_VALUES = [null, undefined, "hello", true, false, {}];
|
||||
for (const val of INVALID_VALUES) {
|
||||
const arr = [1, val, 3];
|
||||
expect(() => Parse.arrayOf(Parse.number)(arr)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.shape, () => {
|
||||
test("Parses objects", () => {
|
||||
const obj = { name: "hello", age: 123 };
|
||||
const shape = { name: Parse.string, age: Parse.number };
|
||||
expect(Parse.shape(shape)(obj)).toEqual(obj);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-object", () => {
|
||||
expect(() => Parse.shape({})("hello")).toThrow(ParseError);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing an object with invalid values", () => {
|
||||
const obj = { name: "hello", age: "world" };
|
||||
const shape = { name: Parse.string, age: Parse.number };
|
||||
expect(() => Parse.shape(shape)(obj)).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.enum, () => {
|
||||
test("Parses valid enum values", () => {
|
||||
const enumValues = ["hello", "world"] as const;
|
||||
expect(Parse.enum(enumValues)("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("Throws an error when parsing an invalid enum value", () => {
|
||||
const enumValues = ["hello", "world"] as const;
|
||||
expect(() => Parse.enum(enumValues)("invalid")).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.regex, () => {
|
||||
test("Parses valid regex values", () => {
|
||||
expect(Parse.regex(/hello/)("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("Throws an error when parsing an invalid regex value", () => {
|
||||
expect(() => Parse.regex(/hello/)("world")).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.optional, () => {
|
||||
test("Parses undefined values", () => {
|
||||
expect(Parse.optional(Parse.string)(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.nullable, () => {
|
||||
test("Parses null values", () => {
|
||||
expect(Parse.nullable(Parse.string)(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.nan, () => {
|
||||
test("Parses NaN", () => {
|
||||
expect(Parse.nan(Number.NaN)).toBe(Number.NaN);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-NaN", () => {
|
||||
const INVALID_VALUES = [null, undefined, "hello", 123, true, false, {}];
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.nan(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
class TestClass { }
|
||||
|
||||
describe(Parse.instanceOf, () => {
|
||||
test("Parses instances of a class", () => {
|
||||
const testClass = new TestClass();
|
||||
expect(Parse.instanceOf(TestClass)(testClass)).toBe(testClass);
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a non-instance", () => {
|
||||
const INVALID_VALUES = [null, undefined, "hello", 123, true, false, {}];
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.instanceOf(TestClass)(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
|
||||
test("Throws an error when parsing an instance of a different class", () => {
|
||||
const testClass = new TestClass();
|
||||
expect(() => Parse.instanceOf(class AnotherClass { })(testClass)).toThrow(
|
||||
ParseError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(Parse.oneOf, () => {
|
||||
test("Parses values that match one of the parsers", () => {
|
||||
const parsers = [Parse.string, Parse.number];
|
||||
expect(Parse.oneOf(parsers)("hello")).toEqual("hello");
|
||||
});
|
||||
|
||||
test("Throws an error when parsing a value that doesn't match any parser", () => {
|
||||
const parsers = [Parse.string, Parse.number];
|
||||
const INVALID_VALUES = [null, undefined, true, false, {}];
|
||||
for (const val of INVALID_VALUES) {
|
||||
expect(() => Parse.oneOf(parsers)(val)).toThrow(ParseError);
|
||||
}
|
||||
});
|
||||
});
|
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": [
|
||||
"esnext"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowJs": false,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
// Files
|
||||
"outDir": "dist",
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue