From 1baf20f6c03d624da8cde1b7793ed7acd0ea8b96 Mon Sep 17 00:00:00 2001 From: Endeavorance Date: Tue, 25 Mar 2025 16:15:04 -0400 Subject: [PATCH] Initial commit --- .gitignore | 34 ++++++ LICENSE | 24 ++++ README.md | 157 ++++++++++++++++++++++++++ biome.json | 31 ++++++ bun.lock | 46 ++++++++ package.json | 29 +++++ src/index.ts | 246 +++++++++++++++++++++++++++++++++++++++++ src/test/parse.test.ts | 228 ++++++++++++++++++++++++++++++++++++++ tsconfig.json | 30 +++++ 9 files changed, 825 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/test/parse.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da548a --- /dev/null +++ b/README.md @@ -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(parser: Parser): (val: unknown) => T[]` +- `Parse.shape(shape: T): (val: unknown) => T` +- `Parse.enum(values: readonly E[]): (val: unknown) => E` +- `Parse.regex(regex: RegExp): (val: unknown) => string` +- `Parse.literal(literal: unknown): (val: unknown) => literal` +- `Parse.instanceOf(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; +``` +## 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` 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; +function parseInteger(val: unknown): Integer { + if (typeof val !== "number" || !Number.isInteger(val) || val > 0) { + throw new ParseTypeMismatch("Integer", val); + } + + return val as NegativeInteger; +} +``` diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..3698a76 --- /dev/null +++ b/biome.json @@ -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" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8076f0e --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..07fec59 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dabd1c7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,246 @@ +declare const __parsed_brand: unique symbol; +export type Brand = T & { [__parsed_brand]: B }; +export type Parser = (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 = (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(val, "number"); + }, + + string(val: unknown): string { + return assertTypeof(val, "string"); + }, + + boolean(val: unknown): boolean { + return assertTypeof(val, "boolean"); + }, + + bigint(val: unknown): bigint { + return assertTypeof(val, "bigint"); + }, + + symbol(val: unknown): symbol { + return assertTypeof(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(classDef: { new(): T }): (val: unknown) => T { + return (val: unknown) => { + if (val instanceof classDef) { + return val; + } + + throw MismatchError(`instanceof ${classDef.name}`, val); + }; + }, + + arrayOf( + 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>>( + parsers: T, + shapeName = "Shape", + ): (val: unknown) => { [K in keyof T]: ReturnType } { + return (val: unknown) => { + if (val === null || typeof val !== "object") { + throw MismatchError("object", val); + } + + const result = {} as { [K in keyof T]: ReturnType }; + + for (const key in parsers) { + if (!(key in val)) { + throw new ParseError(`Missing prop: ${key}`, shapeName); + } + + const value = (val as Record)[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( + 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( + 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(parser: (val: unknown) => T): (val: unknown) => T | undefined { + return (val: unknown) => { + if (val === undefined) { + return undefined; + } + + return parser(val); + }; + }, + + nullable(parser: (val: unknown) => T): (val: unknown) => T | null { + return (val: unknown) => { + if (val === null) { + return null; + } + + return parser(val); + }; + }, + + oneOf[]>( + parsers: T, + shapeName = "Union", + ): (value: unknown) => ReturnType { + return (value: unknown): ReturnType => { + const errors: Error[] = []; + + for (const parser of parsers) { + try { + return parser(value) as ReturnType; + } catch (error) { + errors.push( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + + throw MismatchError(shapeName, value); + }; + }, +} as const; diff --git a/src/test/parse.test.ts b/src/test/parse.test.ts new file mode 100644 index 0000000..a7ad47d --- /dev/null +++ b/src/test/parse.test.ts @@ -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); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..913069a --- /dev/null +++ b/tsconfig.json @@ -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" + } +}