261 lines
5.9 KiB
TypeScript
261 lines
5.9 KiB
TypeScript
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) {
|
|
const value = (val as Record<string, unknown>)[key] ?? undefined;
|
|
|
|
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,
|
|
coerceNull = false,
|
|
): (val: unknown) => T | undefined {
|
|
return (val: unknown) => {
|
|
if (val === undefined || (coerceNull && val === null)) {
|
|
return undefined;
|
|
}
|
|
|
|
return parser(val);
|
|
};
|
|
},
|
|
|
|
nullable<T>(
|
|
parser: (val: unknown) => T,
|
|
coerceUndefined = false,
|
|
): (val: unknown) => T | null {
|
|
return (val: unknown) => {
|
|
if (val === null || (coerceUndefined && val === undefined)) {
|
|
return null;
|
|
}
|
|
|
|
return parser(val);
|
|
};
|
|
},
|
|
|
|
defaulted<T>(
|
|
parser: (val: unknown) => T,
|
|
defaultVal: T,
|
|
): (val: unknown) => T {
|
|
return (val: unknown) => {
|
|
if (val === undefined || val === null) {
|
|
return defaultVal;
|
|
}
|
|
|
|
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;
|