diff --git a/ark/attest/cache/ts.ts b/ark/attest/cache/ts.ts index 48ca8cc921..930eed53a2 100644 --- a/ark/attest/cache/ts.ts +++ b/ark/attest/cache/ts.ts @@ -1,5 +1,5 @@ import { fromCwd, type SourcePosition } from "@ark/fs" -import { printable, throwInternalError, type dict } from "@ark/util" +import { printable, throwError, throwInternalError, type dict } from "@ark/util" import * as tsvfs from "@typescript/vfs" import { readFileSync } from "node:fs" import { dirname, join } from "node:path" @@ -34,7 +34,7 @@ export class TsServer { const system = tsvfs.createFSBackedSystem( tsLibPaths.defaultMapFromNodeModules, - dirname(this.tsConfigInfo.path), + this.tsConfigInfo.path ? dirname(this.tsConfigInfo.path) : fromCwd(), ts ) @@ -105,64 +105,99 @@ export const getAbsolutePosition = ( } export type TsconfigInfo = { - path: string + path: string | undefined parsed: ts.ParsedCommandLine } export const getTsConfigInfoOrThrow = (): TsconfigInfo => { const config = getConfig() const tsconfig = config.tsconfig - const configFilePath = - tsconfig ?? ts.findConfigFile(fromCwd(), ts.sys.fileExists, "tsconfig.json") - if (!configFilePath) { - throw new Error( - `File ${tsconfig ?? join(fromCwd(), "tsconfig.json")} must exist` - ) + + let instantiatedConfig: ts.ParsedCommandLine | undefined + let configFilePath: string | undefined + + if (tsconfig !== null) { + configFilePath = + tsconfig ?? + ts.findConfigFile(fromCwd(), ts.sys.fileExists, "tsconfig.json") + if (configFilePath) + instantiatedConfig = instantiateTsconfigFromPath(configFilePath) } - const configFileText = readFileSync(configFilePath).toString() - const result = ts.parseConfigFileTextToJson(configFilePath, configFileText) - if (result.error) { - throw new Error( - ts.formatDiagnostics([result.error], { - getCanonicalFileName: fileName => fileName, - getCurrentDirectory: process.cwd, - getNewLine: () => ts.sys.newLine - }) - ) + instantiatedConfig ??= instantiateNoFileConfig() + + return { + path: configFilePath, + parsed: instantiatedConfig } +} + +type RawTsConfigJson = dict & { compilerOptions: ts.CompilerOptions } - const configJson: dict & { compilerOptions: ts.CompilerOptions } = - result.config +type InstantiatedTsConfigJson = ts.ParsedCommandLine - configJson.compilerOptions = Object.assign( - configJson.compilerOptions ?? {}, - config.compilerOptions +const instantiateNoFileConfig = (): InstantiatedTsConfigJson => { + const arkConfig = getConfig() + + const instantiatedConfig = ts.parseJsonConfigFileContent( + { + compilerOptions: arkConfig.compilerOptions + }, + ts.sys, + fromCwd() ) - const configParseResult = ts.parseJsonConfigFileContent( - configJson, + + if (instantiatedConfig.errors.length > 0) + throwConfigInstantiationError(instantiatedConfig) + + return instantiatedConfig +} + +const instantiateTsconfigFromPath = ( + path: string +): InstantiatedTsConfigJson => { + const arkConfig = getConfig() + const configFileText = readFileSync(path).toString() + const result = ts.parseConfigFileTextToJson(path, configFileText) + if (result.error) throwConfigParseError(result.error) + + const rawConfig: RawTsConfigJson = result.config + + rawConfig.compilerOptions = Object.assign( + rawConfig.compilerOptions ?? {}, + arkConfig.compilerOptions + ) + + const instantiatedConfig = ts.parseJsonConfigFileContent( + rawConfig, ts.sys, - dirname(configFilePath), + dirname(path), {}, - configFilePath + path ) - if (configParseResult.errors.length > 0) { - throw new Error( - ts.formatDiagnostics(configParseResult.errors, { - getCanonicalFileName: fileName => fileName, - getCurrentDirectory: process.cwd, - getNewLine: () => ts.sys.newLine - }) - ) - } + if (instantiatedConfig.errors.length > 0) + throwConfigInstantiationError(instantiatedConfig) - return { - path: configFilePath, - parsed: configParseResult - } + return instantiatedConfig +} + +const defaultDiagnosticHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: fileName => fileName, + getCurrentDirectory: process.cwd, + getNewLine: () => ts.sys.newLine } +const throwConfigParseError = (error: ts.Diagnostic) => + throwError(ts.formatDiagnostics([error], defaultDiagnosticHost)) + +const throwConfigInstantiationError = ( + instantiatedConfig: InstantiatedTsConfigJson +): never => + throwError( + ts.formatDiagnostics(instantiatedConfig.errors, defaultDiagnosticHost) + ) + type TsLibFiles = { defaultMapFromNodeModules: Map resolvedPaths: string[] diff --git a/ark/attest/config.ts b/ark/attest/config.ts index 360d2d0e3d..907e2170ab 100644 --- a/ark/attest/config.ts +++ b/ark/attest/config.ts @@ -105,6 +105,8 @@ const getParamValue = (param: keyof AttestConfig) => { if (raw === "false") return false + if (raw === "null") return null + if (param === "benchPercentThreshold") return tryParseNumber(raw, { errorOnFail: true }) diff --git a/ark/attest/package.json b/ark/attest/package.json index 035d47ad5d..41c2898994 100644 --- a/ark/attest/package.json +++ b/ark/attest/package.json @@ -1,6 +1,6 @@ { "name": "@ark/attest", - "version": "0.36.0", + "version": "0.37.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/attest/tsVersioning.ts b/ark/attest/tsVersioning.ts index 08314ca712..71786f9723 100644 --- a/ark/attest/tsVersioning.ts +++ b/ark/attest/tsVersioning.ts @@ -15,9 +15,7 @@ import ts from "typescript" * * Throws an error if any version fails when the associated function is executed. * - * @param {TsVersionData[]} versions The set of versions for which to exceute the function - * @param {function} fn - The function to execute for each TypeScript version. - * Should spawn a new process so the new symlinked version can be loaded. + * fn should spawn a new process so the new symlinked version can be loaded. */ export const forTypeScriptVersions = ( versions: TsVersionData[], diff --git a/ark/docs/components/ApiTable.tsx b/ark/docs/components/ApiTable.tsx new file mode 100644 index 0000000000..7616eb3930 --- /dev/null +++ b/ark/docs/components/ApiTable.tsx @@ -0,0 +1,102 @@ +import type { JSX } from "react" +import type { ApiGroup, ParsedJsDocPart } from "../../repo/jsdocGen.ts" +import { apiDocsByGroup } from "./apiData.ts" +import { CodeBlock } from "./CodeBlock.tsx" +import { LocalFriendlyUrl } from "./LocalFriendlyUrl.tsx" + +export type ApiTableProps = { + group: ApiGroup + rows: JSX.Element[] +} + +export const ApiTable = ({ group }: ApiTableProps) => ( + <> +

{group}

+
+ + + + + + + + + {apiDocsByGroup[group].map(props => ( + + ))} + +
+
+ +) + +const ApiTableHeader = () => ( + + + + Name + + Summary + Example + + +) + +interface ApiTableRowProps { + name: string + summary: ParsedJsDocPart[] + example?: string + notes: ParsedJsDocPart[][] +} + +const ApiTableRow = ({ name, summary, example, notes }: ApiTableRowProps) => ( + + {name} + {JsDocParts(summary)} + + {notes.map((note, i) => ( +
{JsDocParts(note)}
+ ))} + {example} + + +) + +const JsDocParts = (parts: readonly ParsedJsDocPart[]) => + parts.map((part, i) => ( + + {part.kind === "link" ? + + {part.value} + + : part.kind === "reference" ? + + {part.value} + + : part.kind === "tag" ? +

+ {part.name} {JsDocParts(part.value)} +

+ :

$2") + .replace(/(\*|_)([^*_]+)\1/g, "$2") + }} + /> + } + + )) + +interface ApiExampleProps { + children: string | undefined +} + +const ApiExample = ({ children }: ApiExampleProps) => + children && ( + + {children} + + ) diff --git a/ark/docs/components/CodeBlock.tsx b/ark/docs/components/CodeBlock.tsx index f54c5f98bd..f0078c8dc0 100644 --- a/ark/docs/components/CodeBlock.tsx +++ b/ark/docs/components/CodeBlock.tsx @@ -19,8 +19,11 @@ export type CodeBlockProps = { style?: React.CSSProperties className?: string includesCompletions?: boolean + decorators?: CodeBlockDecorator[] } & propwiseXor<{ children: string }, { fromFile: SnippetId }> +export type CodeBlockDecorator = "@noErrors" + // preload languages for shiki // https://github.com/fuma-nama/fumadocs/issues/1095 const highlighter = await getSingletonHighlighter({ @@ -51,11 +54,12 @@ export const CodeBlock: React.FC = ({ fromFile, style, className, - includesCompletions + includesCompletions, + decorators }) => { - children ??= snippetContentsById[fromFile!] + let src = children ?? snippetContentsById[fromFile!] - if (!children) { + if (!src) { throwInternalError( fromFile ? `Specified snippet '${fromFile}' does not have a corresponding file` @@ -63,7 +67,11 @@ export const CodeBlock: React.FC = ({ ) } - const highlighted = highlight(lang, children) + decorators?.forEach(d => { + if (!src.includes(d)) src = `// ${d}\n${src}` + }) + + const highlighted = highlight(lang, src) return ( { + const [locallyAccessibleUrl, setLocallyAccessibleUrl] = useState(props.url) + + if (process.env.NODE_ENV === "development") { + useEffect(() => { + const devFriendlyUrl = new URL(props.url) + devFriendlyUrl.protocol = "http:" + devFriendlyUrl.host = window.location.host + setLocallyAccessibleUrl(devFriendlyUrl.toString()) + }, [props.url]) + } + + return ( + + {props.children} + + ) +} diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts new file mode 100644 index 0000000000..9ddcf25143 --- /dev/null +++ b/ark/docs/components/apiData.ts @@ -0,0 +1,253 @@ +import type { ApiDocsByGroup } from "../../repo/jsdocGen.ts" + +export const apiDocsByGroup: ApiDocsByGroup = { + Type: [ + { + group: "Type", + name: "$", + summary: [ + { + kind: "text", + value: "The" + }, + { + kind: "reference", + value: "Scope" + }, + { + kind: "text", + value: + "in which definitions for this Type its chained methods are parsed" + } + ], + notes: [] + }, + { + group: "Type", + name: "infer", + summary: [ + { + kind: "text", + value: "The type of data this returns" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: + "🥸 Inference-only property that will be `undefined` at runtime" + } + ] + ], + example: + 'const parseNumber = type("string").pipe(s => Number.parseInt(s))\ntype ParsedNumber = typeof parseNumber.infer // number' + }, + { + group: "Type", + name: "inferIn", + summary: [ + { + kind: "text", + value: "The type of data this expects" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: + "🥸 Inference-only property that will be `undefined` at runtime" + } + ] + ], + example: + 'const parseNumber = type("string").pipe(s => Number.parseInt(s))\ntype UnparsedNumber = typeof parseNumber.inferIn // string' + }, + { + group: "Type", + name: "json", + summary: [ + { + kind: "text", + value: "The internal JSON representation" + } + ], + notes: [] + }, + { + group: "Type", + name: "toJsonSchema", + summary: [ + { + kind: "text", + value: "Generate a JSON Schema" + } + ], + notes: [] + }, + { + group: "Type", + name: "meta", + summary: [ + { + kind: "text", + value: "Metadata like custom descriptions and error messages" + } + ], + notes: [] + }, + { + group: "Type", + name: "description", + summary: [ + { + kind: "text", + value: "An English description" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: "Work best for primitive values" + } + ] + ], + example: + 'const n = type("0 < number <= 100")\nconsole.log(n.description) // positive and at most 100' + }, + { + group: "Type", + name: "expression", + summary: [ + { + kind: "text", + value: "A syntactic representation similar to native TypeScript" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: "Works well for both primitives and structures" + } + ] + ], + example: + 'const loc = type({ coords: ["number", "number"] })\nconsole.log(loc.expression) // { coords: [number, number] }' + }, + { + group: "Type", + name: "assert", + summary: [ + { + kind: "text", + value: + "Validate and morph data, throwing a descriptive AggregateError on failure" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: "Sugar to avoid checking for" + }, + { + kind: "reference", + value: "type.errors" + }, + { + kind: "text", + value: "if they are unrecoverable" + } + ] + ], + example: + 'const criticalPayload = type({\n superImportantValue: "string"\n})\n// throws AggregateError: superImportantValue must be a string (was missing)\nconst data = criticalPayload.assert({ irrelevantValue: "whoops" })\nconsole.log(data.superImportantValue) // valid output can be accessed directly' + }, + { + group: "Type", + name: "allows", + summary: [ + { + kind: "text", + value: "Validate input data without applying morphs" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: + "Good for cases like filtering that don't benefit from detailed errors" + } + ] + ], + example: + 'const numeric = type("number | bigint")\n// [0, 2n]\nconst numerics = [0, "one", 2n].filter(numeric.allows)' + }, + { + group: "Type", + name: "configure", + summary: [ + { + kind: "text", + value: "Clone and add metadata to shallow references" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: + "Does not affect error messages within properties of an object" + } + ], + [ + { + kind: "noteStart", + value: "Overlapping keys on existing meta will be overwritten" + } + ] + ], + example: + 'const notOdd = type("number % 2").configure({ description: "not odd" })\n// all constraints at the root are affected\nconst odd = notOdd(3) // must be not odd (was 3)\nconst nonNumber = notOdd("two") // must be not odd (was "two")\n\nconst notOddBox = type({\n // we should have referenced notOdd or added meta here\n notOdd: "number % 2",\n// but instead chained from the root object\n}).configure({ description: "not odd" })\n// error message at path notOdd is not affected\nconst oddProp = notOddBox({ notOdd: 3 }) // notOdd must be even (was 3)\n// error message at root is affected, leading to a misleading description\nconst nonObject = notOddBox(null) // must be not odd (was null)' + }, + { + group: "Type", + name: "describe", + summary: [ + { + kind: "text", + value: "Clone and add the description to shallow references" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: "Equivalent to `.configure({ description })` (see" + }, + { + kind: "reference", + value: "configure" + }, + { + kind: "text", + value: ")" + } + ], + [ + { + kind: "noteStart", + value: + "Does not affect error messages within properties of an object" + } + ] + ], + example: + 'const aToZ = type(/^a.*z$/).describe("a string like \'a...z\'")\nconst good = aToZ("alcatraz") // "alcatraz"\n// notice how our description is integrated with other parts of the message\nconst badPattern = aToZ("albatross") // must be a string like \'a...z\' (was "albatross")\nconst nonString = aToZ(123) // must be a string like \'a...z\' (was 123)' + } + ] +} diff --git a/ark/docs/content/docs/configuration/index.mdx b/ark/docs/content/docs/configuration/index.mdx index c92223889a..dffee6c9b2 100644 --- a/ark/docs/content/docs/configuration/index.mdx +++ b/ark/docs/content/docs/configuration/index.mdx @@ -54,7 +54,7 @@ console.log(formData.age) ### jitless -By default, when a `Type` is instantiated, ArkType will precompile optimized validation logic that will run when the type is invoked. This behavior is disabled by default in environments that don't support `new Function`, e.g. Cloudlflare Workers. +By default, when a `Type` is instantiated, ArkType will precompile optimized validation logic that will run when the type is invoked. This behavior is disabled by default in environments that don't support `new Function`, e.g. Cloudflare Workers. If you'd like to opt out of it for another reason, you can set the `jitless` config option to `true`. @@ -74,3 +74,23 @@ const myObject = type({ foo: "string" }) ``` + +### custom + +```ts +// add this anywhere in your project +declare global { + interface ArkEnv { + meta(): { + // meta properties should always be optional + secretIngredient?: string + } + } +} + +// now types you define can specify and access your metadata +const mrPingsSecretIngredientSoup = type({ + broth: "'miso' | 'vegetable'", + ingredients: "string[]" +}).configure({ secretIngredient: "nothing!" }) +``` diff --git a/ark/docs/content/docs/type-api.mdx b/ark/docs/content/docs/type-api.mdx index cdf119bb19..1e9d7482d6 100644 --- a/ark/docs/content/docs/type-api.mdx +++ b/ark/docs/content/docs/type-api.mdx @@ -2,7 +2,9 @@ title: Type API --- -🚧 Coming soon ™️🚧 +import { ApiTable } from "../../components/ApiTable.tsx" + + ### Properties diff --git a/ark/docs/lib/writeSnippetsEntrypoint.ts b/ark/docs/lib/writeSnippetsEntrypoint.ts index 5db7a4ac9f..0866c54330 100644 --- a/ark/docs/lib/writeSnippetsEntrypoint.ts +++ b/ark/docs/lib/writeSnippetsEntrypoint.ts @@ -16,7 +16,7 @@ import { existsSync } from "fs" * the contents.ts file and just import that directly. It is * then committed to git as normal. */ -export const writeSnippetsEntrypoint = () => { +export const updateSnippetsEntrypoint = () => { const snippetContentsById = flatMorph(snippetIds, (i, id) => { const tsPath = snippetPath(`${id}.twoslash.ts`) const jsPath = snippetPath(`${id}.twoslash.js`) @@ -32,12 +32,12 @@ export const writeSnippetsEntrypoint = () => { const toPath = snippetPath("contentsById.ts") - writeFile( - toPath, - `export default ${JSON.stringify(snippetContentsById, null, 4)}` - ) + const contents = `export default ${JSON.stringify(snippetContentsById, null, 4)}` - shell(`pnpm prettier --write ${toPath}`) + if (!existsSync(toPath) || readFile(toPath) !== contents) { + writeFile(toPath, contents) + shell(`pnpm prettier --write ${toPath}`) + } } const snippetIds = [ diff --git a/ark/docs/next.config.ts b/ark/docs/next.config.ts index 8bbedadf64..011c004636 100644 --- a/ark/docs/next.config.ts +++ b/ark/docs/next.config.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-require-imports */ import type { NextConfig } from "next" -import { writeSnippetsEntrypoint } from "./lib/writeSnippetsEntrypoint.ts" +import { updateSnippetsEntrypoint } from "./lib/writeSnippetsEntrypoint.ts" // Next can only treat next.config.ts as CJS, but fumadocs-mdx only supports ESM // This allows us to import it using Node 22+ with --experimental-require-module @@ -9,30 +9,18 @@ import { writeSnippetsEntrypoint } from "./lib/writeSnippetsEntrypoint.ts" const { createMDX } = require("./node_modules/fumadocs-mdx/dist/next/index.js") as typeof import("fumadocs-mdx/next") -writeSnippetsEntrypoint() +updateSnippetsEntrypoint() const config = { reactStrictMode: true, cleanDistDir: true, - serverExternalPackages: ["twoslash", "typescript"], + serverExternalPackages: ["twoslash", "typescript", "ts-morph"], // the following properties are required by nextjs-github-pages: // https://github.com/gregrickaby/nextjs-github-pages output: "export", images: { unoptimized: true } - // redirects: async () => [ - // { - // source: "/docs", - // destination: "/docs/intro/setup", - // permanent: true - // }, - // { - // source: "/docs/intro", - // destination: "/docs/intro/setup", - // permanent: true - // } - // ] } as const satisfies NextConfig const mdxConfig = createMDX()(config) diff --git a/ark/docs/package.json b/ark/docs/package.json index 738a3a9b56..8af8c7cc16 100644 --- a/ark/docs/package.json +++ b/ark/docs/package.json @@ -40,6 +40,7 @@ "react-dom": "19.0.0", "shiki": "1.26.1", "tailwindcss": "3.4.17", + "ts-morph": "25.0.0", "twoslash": "0.2.12", "typescript": "catalog:" } diff --git a/ark/fast-check/package.json b/ark/fast-check/package.json index 0b44c10ad0..8afff29486 100644 --- a/ark/fast-check/package.json +++ b/ark/fast-check/package.json @@ -1,6 +1,6 @@ { "name": "@ark/fast-check", - "version": "0.0.6", + "version": "0.0.7", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/fs/fs.ts b/ark/fs/fs.ts index 816557c83e..0e46213ac3 100644 --- a/ark/fs/fs.ts +++ b/ark/fs/fs.ts @@ -154,7 +154,10 @@ export const findPackageAncestors = (fromDir?: string): string[] => { while (dir) { dir = findPackageRoot(dir) - if (dir) dirs.push(dir) + if (dir) { + dirs.push(dir) + dir = join(dir, "..") + } } return dirs diff --git a/ark/fs/package.json b/ark/fs/package.json index 92cbd03709..4f6b4e6da2 100644 --- a/ark/fs/package.json +++ b/ark/fs/package.json @@ -1,6 +1,6 @@ { "name": "@ark/fs", - "version": "0.32.0", + "version": "0.33.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/repo/build.ts b/ark/repo/build.ts index cc22751b8c..f359d94911 100644 --- a/ark/repo/build.ts +++ b/ark/repo/build.ts @@ -1,11 +1,20 @@ import { copyFileSync } from "fs" import { join } from "path" // eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { fromCwd, fromHere, rmRf, shell, writeJson } from "../fs/index.ts" +import { + fromCwd, + fromHere, + readPackageJson, + rmRf, + shell, + writeJson +} from "../fs/index.ts" +import { buildApi } from "./jsdocGen.ts" const buildKind = process.argv.includes("--cjs") || process.env.ARKTYPE_CJS ? "cjs" : "esm" const outDir = fromCwd("out") +const packageName = readPackageJson(process.cwd()).name const buildCurrentProject = () => shell( @@ -22,6 +31,7 @@ try { buildCurrentProject() if (buildKind === "cjs") writeJson(join(outDir, "package.json"), { type: "commonjs" }) + if (packageName === "arktype") buildApi() } finally { rmRf("tsconfig.build.json") } diff --git a/ark/repo/jsdocGen.ts b/ark/repo/jsdocGen.ts new file mode 100644 index 0000000000..45c78c30ae --- /dev/null +++ b/ark/repo/jsdocGen.ts @@ -0,0 +1,361 @@ +import { existsSync } from "fs" +import { join } from "path" +import { + Project, + SyntaxKind, + type JSDoc, + type JSDocableNode, + type Node, + type SourceFile +} from "ts-morph" +import ts from "typescript" +import { bootstrapFs, bootstrapUtil, repoDirs } from "./shared.ts" + +const { flatMorph, includes, throwInternalError } = bootstrapUtil +const { writeFile, shell } = bootstrapFs + +const inheritDocToken = "@inheritDoc" +const typeOnlyToken = "@typeonly" +const typeOnlyMessage = + "- 🥸 Inference-only property that will be `undefined` at runtime" +const typeNoopToken = "@typenoop" +const typeNoopMessage = "- 🥸 Inference-only function that does nothing runtime" + +const arkTypeBuildDir = join(repoDirs.arkDir, "type", "out") +const jsdocSourcesGlob = `${arkTypeBuildDir}/**/*.d.ts` + +let updateCount = 0 + +export const buildApi = () => { + const project = createProject() + jsdocGen(project) + const docs = getAllJsDoc(project) + + const apiDocsByGroup = flatMorph(docs, (i, doc) => { + const block = parseBlock(doc) + + if (!block) return [] + + return [{ group: block.group }, block] + }) + + const apiDataPath = join(repoDirs.docs, "components", "apiData.ts") + + writeFile( + apiDataPath, + `import type { ApiDocsByGroup } from "../../repo/jsdocGen.ts" + +export const apiDocsByGroup: ApiDocsByGroup = ${JSON.stringify(apiDocsByGroup, null, 4)}` + ) + + shell(`prettier --write ${apiDataPath}`) +} + +export const jsdocGen = (project: Project) => { + const sourceFiles = project.getSourceFiles() + + console.log( + `✍️ Generating JSDoc for ${sourceFiles.length} files in ${arkTypeBuildDir}...` + ) + + project.getSourceFiles().forEach(docgenForFile) + + project.saveSync() + + console.log( + `📚 Successfully updated ${updateCount} JSDoc comments on your build output.` + ) +} + +export const getAllJsDoc = (project: Project) => { + const sourceFiles = project.getSourceFiles() + + return sourceFiles.flatMap(file => + file.getDescendantsOfKind(SyntaxKind.JSDoc) + ) +} + +const apiGroups = ["Type"] as const + +export type ApiGroup = (typeof apiGroups)[number] + +export type JsdocComment = ReturnType + +export type JsdocPart = Extract[number] & {} + +export type ParsedJsDocPart = ShallowJsDocPart | ParsedJsDocTag + +export type ShallowJsDocPart = + | { kind: "text"; value: string } + | { kind: "noteStart"; value: string } + | { kind: "reference"; value: string } + | { kind: "link"; value: string; url: string } + +export type ParsedJsDocTag = { + kind: "tag" + name: string + value: ParsedJsDocPart[] +} + +export type ApiDocsByGroup = { + readonly [k in ApiGroup]: readonly ParsedJsDocBlock[] +} + +export type ParsedJsDocBlock = { + group: ApiGroup + name: string + summary: ParsedJsDocPart[] + notes: ParsedJsDocPart[][] + example?: string +} + +const createProject = () => { + const project = new Project() + + if (!existsSync(arkTypeBuildDir)) { + throw new Error( + `jsdocGen rewrites ${arkTypeBuildDir} but that directory doesn't exist. Did you run "pnpm build" there first?` + ) + } + + project.addSourceFilesAtPaths(jsdocSourcesGlob) + + return project +} + +const parseBlock = (doc: JSDoc): ParsedJsDocBlock | undefined => { + const name = doc.getNextSiblingIfKind(SyntaxKind.Identifier)?.getText() + + if (!name) return + + const tags = doc.getTags() + const group = tags.find(t => t.getTagName() === "api")?.getCommentText() + + if (!group) return + + if (!includes(apiGroups, group)) { + return throwInternalError( + `Invalid API group ${group} for name ${name}. Should be defined like @api Type` + ) + } + + const rootComment = doc.getComment() + + if (!rootComment) + return throwInternalError(`Expected root comment for ${group}/${name}`) + + const allParts: ParsedJsDocPart[] = + typeof rootComment === "string" ? + parseJsdocText(rootComment) + // remove any undefined parts before parsing + : rootComment.filter(part => !!part).flatMap(parseJsdocPart) + + const summaryParts: ParsedJsDocPart[] = [] + const notePartGroups: ParsedJsDocPart[][] = [] + + allParts.forEach(part => { + if (part.kind === "noteStart") notePartGroups.push([part]) + else if (notePartGroups.length) notePartGroups.at(-1)!.push(part) + else summaryParts.push(part) + }) + + const result: ParsedJsDocBlock = { + group, + name, + summary: summaryParts, + notes: notePartGroups + } + + const example = tags.find(t => t.getTagName() === "example")?.getCommentText() + if (example) result.example = example + + return result +} + +const parseJsdocPart = (part: JsdocPart): ParsedJsDocPart[] => { + switch (part.getKindName()) { + case "JSDocText": + return parseJsdocText(part.compilerNode.text) + case "JSDocLink": + return [parseJsdocLink(part)] + default: + return throwInternalError( + `Unsupported JSDoc part kind ${part.getKindName()} at position ${part.getPos()} in ${part.getSourceFile().getFilePath()}` + ) + } +} + +const parseJsdocText = (text: string): ParsedJsDocPart[] => { + const sections = text.split(/\n\s*-/) + return sections.map((sectionText, i) => ({ + kind: i === 0 ? "text" : "noteStart", + value: sectionText.trim() + })) +} + +const describedLinkRegex = + /\{@link\s+(https?:\/\/[^\s|}]+)(?:\s*\|\s*([^}]*))?\}/ + +const parseJsdocLink = (part: JsdocPart): ParsedJsDocPart => { + const linkText = part.getText() + const match = describedLinkRegex.exec(linkText) + if (match) { + const url = match[1].trim() + const value = match[2]?.trim() || url + return { kind: "link", url, value } + } + + const referencedName = part + .getChildren() + .find( + child => + child.isKind(SyntaxKind.Identifier) || + child.isKind(SyntaxKind.QualifiedName) + ) + ?.getText() + + if (!referencedName) { + return throwInternalError( + `Unable to parse referenced name from ${part.getText()}` + ) + } + + return { + kind: "reference", + value: referencedName + } +} + +type MatchContext = { + matchedJsdoc: JSDoc + updateJsdoc: (text: string) => void + inheritDocsSource: string | undefined +} + +const docgenForFile = (sourceFile: SourceFile) => { + const path = sourceFile.getFilePath() + + const jsdocNodes = sourceFile.getDescendantsOfKind(SyntaxKind.JSDoc) + + const matchContexts: MatchContext[] = jsdocNodes.flatMap(jsdoc => { + const text = jsdoc.getText() + + const inheritDocsSource = extractInheritDocName(path, text) + + if ( + !inheritDocsSource && + !text.includes(typeOnlyToken) && + !text.includes(typeNoopToken) + ) + return [] + + return { + matchedJsdoc: jsdoc, + inheritDocsSource, + updateJsdoc: text => { + const parent = jsdoc.getParent() as JSDocableNode + + // replace the original JSDoc node in the AST with a new one + // created from updatedContents + jsdoc.remove() + parent.addJsDoc(text) + + updateCount++ + } + } + }) + + matchContexts.forEach(ctx => { + const inheritedDocs = findInheritedDocs(sourceFile, ctx) + + let updatedContents = ctx.matchedJsdoc.getInnerText() + + if (inheritedDocs) + updatedContents = `${inheritedDocs.originalSummary}\n${inheritedDocs.inheritedDescription}` + + updatedContents = updatedContents.replace(typeOnlyToken, typeOnlyMessage) + updatedContents = updatedContents.replace(typeNoopToken, typeNoopMessage) + + ctx.updateJsdoc(updatedContents) + }) +} + +const findInheritedDocs = ( + sourceFile: SourceFile, + { inheritDocsSource, matchedJsdoc }: MatchContext +) => { + if (!inheritDocsSource) return + + const sourceDeclaration = sourceFile + .getDescendantsOfKind(ts.SyntaxKind.Identifier) + .find(i => i.getText() === inheritDocsSource) + ?.getDefinitions()[0] + .getDeclarationNode() + + if (!sourceDeclaration || !canHaveJsDoc(sourceDeclaration)) return + + const matchedDescription = matchedJsdoc.getDescription() + + const inheritedDescription = sourceDeclaration.getJsDocs()[0].getDescription() + + const originalSummary = matchedDescription + .slice(0, matchedDescription.indexOf("{")) + .trim() + + return { + originalSummary, + inheritedDescription + } +} + +const extractInheritDocName = ( + path: string, + text: string +): string | undefined => { + const inheritDocTokenIndex = text.indexOf(inheritDocToken) + + if (inheritDocTokenIndex === -1) return + + const prefix = text.slice(0, inheritDocTokenIndex) + + const openBraceIndex = prefix.trimEnd().length - 1 + + if (text[openBraceIndex] !== "{") { + throwJsDocgenParseError( + path, + text, + ` Expected '{' before @inheritDoc but got '${text[openBraceIndex]}'` + ) + } + + const openTagEndIndex = inheritDocTokenIndex + inheritDocToken.length + + const textFollowingOpenTag = text.slice(openTagEndIndex) + + const innerTagLength = textFollowingOpenTag.indexOf("}") + + if (innerTagLength === -1) { + throwJsDocgenParseError( + path, + text, + `Expected '}' after @inheritDoc but got '${textFollowingOpenTag[0]}'` + ) + } + + const sourceName = textFollowingOpenTag.slice(0, innerTagLength).trim() + + return sourceName +} + +const canHaveJsDoc = (node: Node): node is Node & JSDocableNode => + "addJsDoc" in node + +const throwJsDocgenParseError = ( + path: string, + commentText: string, + message: string +): never => { + throw new Error( + `jsdocGen ParseError in ${path}: ${message}\nComment text: ${commentText}` + ) +} diff --git a/ark/repo/nodeOptions.js b/ark/repo/nodeOptions.js index 8eba2d0d4a..c629a0fdba 100644 --- a/ark/repo/nodeOptions.js +++ b/ark/repo/nodeOptions.js @@ -12,8 +12,5 @@ const versionedFlags = export const nodeDevOptions = `${process.env.NODE_OPTIONS ?? ""} --conditions ark-ts ${versionedFlags}` -/** - * @param {string} [extraOpts] - */ export const addNodeDevOptions = extraOpts => (process.env.NODE_OPTIONS = `${nodeDevOptions} ${extraOpts ?? ""}`) diff --git a/ark/repo/package.json b/ark/repo/package.json index 3c90ce268c..5dae015be3 100644 --- a/ark/repo/package.json +++ b/ark/repo/package.json @@ -10,6 +10,7 @@ "@trpc/server": "10.45.2", "arktype": "workspace:*", "type-fest": "4.31.0", + "ts-morph": "25.0.0", "typescript": "catalog:", "zod": "3.24.1" }, diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 6bafc73f89..9d7296bdd2 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -1,6 +1,5 @@ -import { attest } from "@ark/attest" -import { flatMorph } from "@ark/util" -import { ark, type } from "arktype" +import { type } from "arktype" +import { buildApi, jsdocGen } from "./jsdocGen.ts" // type stats on attribute removal merge 12/18/2024 // { @@ -9,30 +8,19 @@ import { ark, type } from "arktype" // "instantiations": 5066185 // } -// false -// const t = type({ foo: "string" }).extends("Record") +const t = type("(number % 2) > 0") -flatMorph(ark.internal.resolutions, (k, v) => [k, v]) +// buildApi() -console.log(Object.keys(ark.internal.resolutions)) +t.configure -const customEven = type("number % 2", "@", { - expected: ctx => `custom expected ${ctx.description}`, - actual: data => `custom actual ${data}`, - problem: ctx => `custom problem ${ctx.expected} ${ctx.actual}`, - message: ctx => `custom message ${ctx.problem}` -}) +t.description //? +// an integer and more than 0 and at most 10 -// custom message custom problem custom expected a multiple of 2 custom actual 3 -customEven(3) - -type Thing1 = { - [x: string]: unknown -} - -// Thing2 is apparently identical to Thing1, and yet... -type Thing2 = Record - -type A = keyof Thing1 // number | string - -type B = keyof Thing2 // string +const text = `FOo bar +- baz + - back + track + -squz` +const s = text.split(/\n\s*-/) +console.log(s) diff --git a/ark/repo/shared.ts b/ark/repo/shared.ts index a89db19263..0a00a7bd62 100644 --- a/ark/repo/shared.ts +++ b/ark/repo/shared.ts @@ -1,9 +1,26 @@ -import { fromHere, readJson } from "@ark/fs" -import { flatMorph } from "@ark/util" +import { + fileName, + findPackageAncestors, + readJson, + readPackageJson +} from "@ark/fs" +import { flatMorph, throwInternalError } from "@ark/util" import { join } from "node:path" import type { PackageJson } from "type-fest" -const root = fromHere("..", "..") +// allow other utils invoked from build to bootstrap utils + +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +export * as bootstrapFs from "../fs/index.ts" +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +export * as bootstrapUtil from "../util/index.ts" + +const root = findPackageAncestors().find( + dir => readPackageJson(dir).name === "ark" +) + +if (!root) throwInternalError(`Can't find repo root from ${fileName()}!`) + const arkDir = join(root, "ark") const docs = join(arkDir, "docs") diff --git a/ark/schema/__tests__/errors.test.ts b/ark/schema/__tests__/errors.test.ts index a2d9bdf1db..08dd353ee1 100644 --- a/ark/schema/__tests__/errors.test.ts +++ b/ark/schema/__tests__/errors.test.ts @@ -65,9 +65,7 @@ contextualize(() => { attest(evenNumber.description).snap("an even number") // since the error is from the divisor constraint which didn't have a // description, it is unchanged - attest(evenNumber.traverse(5)?.toString()).snap( - "must be a multiple of 2 (was 5)" - ) + attest(evenNumber.traverse(5)?.toString()).snap("must be even (was 5)") }) it("can configure error writers at a node level", () => { diff --git a/ark/schema/__tests__/jsonSchema.test.ts b/ark/schema/__tests__/jsonSchema.test.ts index b1b055b48f..4d6a60061a 100644 --- a/ark/schema/__tests__/jsonSchema.test.ts +++ b/ark/schema/__tests__/jsonSchema.test.ts @@ -1,11 +1,5 @@ import { attest, contextualize } from "@ark/attest" -import { - $ark, - intrinsic, - rootSchema, - writeCyclicJsonSchemaMessage, - writeJsonSchemaMorphMessage -} from "@ark/schema" +import { $ark, intrinsic, JsonSchema, rootSchema } from "@ark/schema" contextualize(() => { it("base primitives", () => { @@ -216,13 +210,13 @@ contextualize(() => { }) attest(() => morph.toJsonSchema()).throws( - writeJsonSchemaMorphMessage(morph.expression) + JsonSchema.writeUnjsonifiableMessage(morph.expression, "morph") ) }) it("errors on cyclic", () => { attest(() => $ark.intrinsic.json.toJsonSchema()).throws( - writeCyclicJsonSchemaMessage("jsonObject") + JsonSchema.writeUnjsonifiableMessage("jsonObject", "cyclic") ) }) }) diff --git a/ark/schema/package.json b/ark/schema/package.json index 78094e53df..3fedece9b7 100644 --- a/ark/schema/package.json +++ b/ark/schema/package.json @@ -1,6 +1,6 @@ { "name": "@ark/schema", - "version": "0.32.0", + "version": "0.33.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/schema/predicate.ts b/ark/schema/predicate.ts index b489ec289b..fae6ceec26 100644 --- a/ark/schema/predicate.ts +++ b/ark/schema/predicate.ts @@ -1,4 +1,3 @@ -import { throwParseError } from "@ark/util" import { BaseConstraint } from "./constraint.ts" import type { NodeCompiler } from "./shared/compile.ts" import type { @@ -11,7 +10,7 @@ import { implementNode, type nodeImplementationOf } from "./shared/implement.ts" -import { writeUnsupportedJsonSchemaTypeMessage } from "./shared/jsonSchema.ts" +import { JsonSchema } from "./shared/jsonSchema.ts" import { type RegisteredReference, registeredReference @@ -110,11 +109,7 @@ export class PredicateNode extends BaseConstraint { } reduceJsonSchema(): never { - return throwParseError( - writeUnsupportedJsonSchemaTypeMessage({ - description: `Predicate ${this.expression}` - }) - ) + return JsonSchema.throwUnjsonifiableError(`Predicate ${this.expression}`) } } @@ -128,7 +123,13 @@ export type Predicate = ( ctx: TraversalContext ) => boolean -export type PredicateCast = ( - input: input, - ctx: TraversalContext -) => input is narrowed +export declare namespace Predicate { + export type Casted = ( + input: input, + ctx: TraversalContext + ) => input is narrowed + + export type Castable = + | Predicate + | Casted +} diff --git a/ark/schema/refinements/after.ts b/ark/schema/refinements/after.ts index 7a2fd65416..9c70df7612 100644 --- a/ark/schema/refinements/after.ts +++ b/ark/schema/refinements/after.ts @@ -1,14 +1,11 @@ -import { describeCollapsibleDate, throwParseError } from "@ark/util" +import { describeCollapsibleDate } from "@ark/util" import type { BaseRoot } from "../roots/root.ts" import type { BaseErrorContext, declareNode } from "../shared/declare.ts" import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { @@ -80,9 +77,7 @@ export class AfterNode extends BaseRange { traverseAllows: TraverseAllows = data => data >= this.rule reduceJsonSchema(): JsonSchema { - return throwParseError( - writeUnsupportedJsonSchemaTypeMessage("Date instance") - ) + return JsonSchema.throwUnjsonifiableError("Date instance") } } diff --git a/ark/schema/refinements/before.ts b/ark/schema/refinements/before.ts index c6ee8fd221..b897f87274 100644 --- a/ark/schema/refinements/before.ts +++ b/ark/schema/refinements/before.ts @@ -1,4 +1,4 @@ -import { describeCollapsibleDate, throwParseError } from "@ark/util" +import { describeCollapsibleDate } from "@ark/util" import type { BaseRoot } from "../roots/root.ts" import type { BaseErrorContext, declareNode } from "../shared/declare.ts" import { Disjoint } from "../shared/disjoint.ts" @@ -6,10 +6,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { @@ -87,9 +84,7 @@ export class BeforeNode extends BaseRange { impliedBasis: BaseRoot = $ark.intrinsic.Date.internal reduceJsonSchema(): JsonSchema { - return throwParseError( - writeUnsupportedJsonSchemaTypeMessage("Date instance") - ) + return JsonSchema.throwUnjsonifiableError("Date instance") } } diff --git a/ark/schema/refinements/divisor.ts b/ark/schema/refinements/divisor.ts index 933155a6fd..d7454fbc08 100644 --- a/ark/schema/refinements/divisor.ts +++ b/ark/schema/refinements/divisor.ts @@ -54,7 +54,9 @@ const implementation: nodeImplementationOf = hasAssociatedError: true, defaults: { description: node => - node.rule === 1 ? "an integer" : `a multiple of ${node.rule}` + node.rule === 1 ? "an integer" + : node.rule === 2 ? "even" + : `a multiple of ${node.rule}` }, intersections: { divisor: (l, r, ctx) => @@ -63,7 +65,8 @@ const implementation: nodeImplementationOf = (l.rule * r.rule) / greatestCommonDivisor(l.rule, r.rule) ) }) - } + }, + obviatesBasisDescription: true }) export class DivisorNode extends InternalPrimitiveConstraint { diff --git a/ark/schema/refinements/exactLength.ts b/ark/schema/refinements/exactLength.ts index 2d9cabd06c..6e8ceaca28 100644 --- a/ark/schema/refinements/exactLength.ts +++ b/ark/schema/refinements/exactLength.ts @@ -10,10 +10,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - throwInternalJsonSchemaOperandError, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { createLengthRuleParser, type LengthBoundableData } from "./range.ts" @@ -102,7 +99,7 @@ export class ExactLengthNode extends InternalPrimitiveConstraint = normalize: schema => typeof schema === "number" ? { rule: schema } : schema, defaults: { - description: node => - `${node.exclusive ? "less than" : "at most"} ${node.rule}` + description: node => { + if (node.rule === 0) return node.exclusive ? "negative" : "non-positive" + return `${node.exclusive ? "less than" : "at most"} ${node.rule}` + } }, intersections: { max: (l, r) => (l.isStricterThan(r) ? l : r), @@ -65,7 +67,8 @@ const implementation: nodeImplementationOf = ctx.$.node("unit", { unit: max.rule }) : null : Disjoint.init("range", max, min) - } + }, + obviatesBasisDescription: true }) export class MaxNode extends BaseRange { diff --git a/ark/schema/refinements/maxLength.ts b/ark/schema/refinements/maxLength.ts index b26d4b0ed7..455712582f 100644 --- a/ark/schema/refinements/maxLength.ts +++ b/ark/schema/refinements/maxLength.ts @@ -5,10 +5,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - throwInternalJsonSchemaOperandError, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { @@ -97,7 +94,7 @@ export class MaxLengthNode extends BaseRange { schema.maxItems = this.rule return schema default: - return throwInternalJsonSchemaOperandError("maxLength", schema) + return JsonSchema.throwInternalOperandError("maxLength", schema) } } } diff --git a/ark/schema/refinements/min.ts b/ark/schema/refinements/min.ts index 1aefbe659e..a62f0e3853 100644 --- a/ark/schema/refinements/min.ts +++ b/ark/schema/refinements/min.ts @@ -53,12 +53,15 @@ const implementation: nodeImplementationOf = normalize: schema => typeof schema === "number" ? { rule: schema } : schema, defaults: { - description: node => - `${node.exclusive ? "more than" : "at least"} ${node.rule}` + description: node => { + if (node.rule === 0) return node.exclusive ? "positive" : "non-negative" + return `${node.exclusive ? "more than" : "at least"} ${node.rule}` + } }, intersections: { min: (l, r) => (l.isStricterThan(r) ? l : r) - } + }, + obviatesBasisDescription: true }) export class MinNode extends BaseRange { diff --git a/ark/schema/refinements/minLength.ts b/ark/schema/refinements/minLength.ts index 2dbf484e5f..210b0c812e 100644 --- a/ark/schema/refinements/minLength.ts +++ b/ark/schema/refinements/minLength.ts @@ -5,10 +5,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - throwInternalJsonSchemaOperandError, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { @@ -96,7 +93,7 @@ export class MinLengthNode extends BaseRange { schema.minItems = this.rule return schema default: - return throwInternalJsonSchemaOperandError("minLength", schema) + return JsonSchema.throwInternalOperandError("minLength", schema) } } } diff --git a/ark/schema/refinements/pattern.ts b/ark/schema/refinements/pattern.ts index accc599758..ff712e2d55 100644 --- a/ark/schema/refinements/pattern.ts +++ b/ark/schema/refinements/pattern.ts @@ -1,4 +1,3 @@ -import { throwParseError } from "@ark/util" import { InternalPrimitiveConstraint } from "../constraint.ts" import type { BaseRoot } from "../roots/root.ts" import type { @@ -10,10 +9,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" export declare namespace Pattern { @@ -85,10 +81,8 @@ export class PatternNode extends InternalPrimitiveConstraint { diff --git a/ark/schema/roots/domain.ts b/ark/schema/roots/domain.ts index c3683a3bee..8a053285e0 100644 --- a/ark/schema/roots/domain.ts +++ b/ark/schema/roots/domain.ts @@ -1,9 +1,4 @@ -import { - domainDescriptions, - domainOf, - throwParseError, - type Domain as _Domain -} from "@ark/util" +import { domainDescriptions, domainOf, type Domain as _Domain } from "@ark/util" import type { BaseErrorContext, BaseNormalizedSchema, @@ -14,10 +9,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { InternalBasis } from "./basis.ts" @@ -97,7 +89,7 @@ export class DomainNode extends InternalBasis { protected innerToJsonSchema(): JsonSchema.Constrainable { if (this.domain === "bigint" || this.domain === "symbol") - return throwParseError(writeUnsupportedJsonSchemaTypeMessage(this.domain)) + return JsonSchema.throwUnjsonifiableError(this.domain) return { type: this.domain } diff --git a/ark/schema/roots/intersection.ts b/ark/schema/roots/intersection.ts index da9d2aa55f..87db958251 100644 --- a/ark/schema/roots/intersection.ts +++ b/ark/schema/roots/intersection.ts @@ -223,11 +223,31 @@ const implementation: nodeImplementationOf = pipe: false }) as nodeOfKind<"intersection" | Intersection.BasisKind>, defaults: { - description: node => - node.children.length === 0 ? - "unknown" - : (node.structure?.description ?? - node.children.map(child => child.description).join(" and ")), + description: node => { + if (node.children.length === 0) return "unknown" + if (node.structure) return node.structure.description + + const childDescriptions: string[] = [] + + if ( + node.basis && + !node.refinements.some(r => r.impl.obviatesBasisDescription) + ) + childDescriptions.push(node.basis.description) + + if (node.refinements.length) { + const sortedRefinementDescriptions = node.refinements + // override alphabetization to describe min before max + .toSorted((l, r) => (l.kind === "min" && r.kind === "max" ? -1 : 0)) + .map(r => r.description) + childDescriptions.push(...sortedRefinementDescriptions) + } + + if (node.predicate) + childDescriptions.push(...node.predicate.map(p => p.description)) + + return childDescriptions.join(" and ") + }, expected: source => ` • ${source.errors.map(e => e.expected).join("\n • ")}`, problem: ctx => `(${ctx.actual}) must be...\n${ctx.expected}` diff --git a/ark/schema/roots/morph.ts b/ark/schema/roots/morph.ts index 3dc9f9106b..6ead9d9bd5 100644 --- a/ark/schema/roots/morph.ts +++ b/ark/schema/roots/morph.ts @@ -16,10 +16,7 @@ import { type RootKind } from "../shared/implement.ts" import { intersectOrPipeNodes } from "../shared/intersections.ts" -import { - writeJsonSchemaMorphMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark, registeredReference } from "../shared/registry.ts" import type { TraversalContext, @@ -192,7 +189,7 @@ export class MorphNode extends BaseRoot { } protected innerToJsonSchema(): JsonSchema { - return throwParseError(writeJsonSchemaMorphMessage(this.expression)) + return JsonSchema.throwUnjsonifiableError(this.expression, "morph") } compile(js: NodeCompiler): void { diff --git a/ark/schema/roots/proto.ts b/ark/schema/roots/proto.ts index 420d9d9bdc..8083761a35 100644 --- a/ark/schema/roots/proto.ts +++ b/ark/schema/roots/proto.ts @@ -4,7 +4,6 @@ import { getBuiltinNameOfConstructor, objectKindDescriptions, objectKindOrDomainOf, - throwParseError, type BuiltinObjectKind, type Constructor } from "@ark/util" @@ -19,10 +18,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { isNode } from "../shared/utils.ts" @@ -120,9 +116,7 @@ export class ProtoNode extends InternalBasis { type: "array" } default: - return throwParseError( - writeUnsupportedJsonSchemaTypeMessage(this.description) - ) + return JsonSchema.throwUnjsonifiableError(this.description) } } diff --git a/ark/schema/roots/root.ts b/ark/schema/roots/root.ts index 4496c25707..0c90a9addd 100644 --- a/ark/schema/roots/root.ts +++ b/ark/schema/roots/root.ts @@ -519,7 +519,7 @@ export abstract class BaseRoot< return this.onUndeclaredKey({ rule: behavior, deep: true }) } - satisfying(predicate: Predicate): BaseRoot { + filter(predicate: Predicate): BaseRoot { return this.constrain("predicate", predicate) } diff --git a/ark/schema/roots/unit.ts b/ark/schema/roots/unit.ts index d68cb1e850..ef4efd8f14 100644 --- a/ark/schema/roots/unit.ts +++ b/ark/schema/roots/unit.ts @@ -2,7 +2,6 @@ import { domainDescriptions, domainOf, printable, - throwParseError, type Domain, type JsonPrimitive } from "@ark/util" @@ -17,10 +16,7 @@ import { implementNode, type nodeImplementationOf } from "../shared/implement.ts" -import { - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" import type { TraverseAllows } from "../shared/traversal.ts" import { InternalBasis } from "./basis.ts" @@ -137,9 +133,7 @@ export class UnitNode extends InternalBasis { protected innerToJsonSchema(): JsonSchema { return $ark.intrinsic.jsonPrimitive.allows(this.unit) ? { const: this.unit } - : throwParseError( - writeUnsupportedJsonSchemaTypeMessage(this.shortDescription) - ) + : JsonSchema.throwUnjsonifiableError(this.shortDescription) } traverseAllows: TraverseAllows = diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index 5c7de93425..83ec6e3ddb 100644 --- a/ark/schema/shared/errors.ts +++ b/ark/schema/shared/errors.ts @@ -99,6 +99,12 @@ export class ArkError< } } +/** + * A ReadonlyArray of `ArkError`s returned by a Type on invalid input. + * + * Subsequent errors added at an existing path are merged into an + * ArkError intersection. + */ export class ArkErrors extends ReadonlyArray implements StandardSchemaV1.FailureResult @@ -110,17 +116,56 @@ export class ArkErrors this.ctx = ctx } + /** + * Errors by a pathString representing their location. + */ byPath: Record = Object.create(null) + + /** + * All pathStrings at which errors are present mapped to the errors occuring + * at that path or any nested path within it. + */ byAncestorPath: Record = Object.create(null) count = 0 private mutable: ArkError[] = this as never + /** + * Throw an AggregateError based on these errors. + */ + throw(): never { + throw new AggregateError(this, this.summary) + } + + /** + * Append an ArkError to this array, ignoring duplicates. + * + * Add + */ add(error: ArkError): void { if (this.includes(error)) return this._add(error) } + /** + * Add all errors from an ArkErrors instance, ignoring duplicates and + * prefixing their paths with that of the current TraversalContext. + */ + merge(errors: ArkErrors): void { + errors.forEach(e => { + if (this.includes(e)) return + this._add( + new ArkError( + { ...e, path: [...this.ctx.path, ...e.path] } as never, + this.ctx + ) + ) + }) + } + + /** + * @internal + */ affectsPath(path: ReadonlyPath): boolean { if (this.length === 0) return false @@ -134,6 +179,31 @@ export class ArkErrors ) } + /** + * A human-readable summary of all errors. + */ + get summary(): string { + return this.toString() + } + + /** + * Alias of `summary` for StandardSchema compatibility. + */ + get message(): string { + return this.toString() + } + + /** + * Alias of this ArkErrors instance for StandardSchema compatibility. + */ + get issues(): this { + return this + } + + toString(): string { + return this.join("\n") + } + private _add(error: ArkError): void { const existing = this.byPath[error.propString] if (existing) { @@ -172,38 +242,6 @@ export class ArkErrors ) }) } - - merge(errors: ArkErrors): void { - errors.forEach(e => { - if (this.includes(e)) return - this._add( - new ArkError( - { ...e, path: [...this.ctx.path, ...e.path] } as never, - this.ctx - ) - ) - }) - } - - get summary(): string { - return this.toString() - } - - get message(): string { - return this.toString() - } - - get issues(): this { - return this - } - - toString(): string { - return this.join("\n") - } - - throw(): never { - throw new AggregateError(this, this.message) - } } export type ArkErrorsMergeOptions = { diff --git a/ark/schema/shared/implement.ts b/ark/schema/shared/implement.ts index d5a9d5b8cd..b6696b91f3 100644 --- a/ark/schema/shared/implement.ts +++ b/ark/schema/shared/implement.ts @@ -308,6 +308,7 @@ interface CommonNodeImplementationInput { inner: d["inner"], $: BaseScope ) => nodeOfKind | Disjoint | undefined + obviatesBasisDescription?: d["kind"] extends RefinementKind ? true : never } export interface UnknownNodeImplementation diff --git a/ark/schema/shared/jsonSchema.ts b/ark/schema/shared/jsonSchema.ts index 4709223423..657a1bdec7 100644 --- a/ark/schema/shared/jsonSchema.ts +++ b/ark/schema/shared/jsonSchema.ts @@ -1,6 +1,9 @@ import { + isKeyOf, printable, + throwError, throwInternalError, + type autocomplete, type JsonArray, type JsonObject, type listable @@ -81,40 +84,49 @@ export declare namespace JsonSchema { export type LengthBoundable = String | Array export type Structure = Object | Array + + export type UnjsonifiableError = InstanceType< + typeof JsonSchema.UnjsonifiableError + > } -export type UnsupportedJsonSchemaTypeMessageOptions = { - description: string - reason?: string +const unjsonifiableExplanations = { + morph: + "it represents a transformation, while JSON Schema only allows validation. Consider creating a Schema from one of its endpoints using `.in` or `.out`.", + cyclic: + "cyclic types are not yet convertible to JSON Schema. If this feature is important to you, please add your feedback at https://github.com/arktypeio/arktype/issues/1087" } -export const writeUnsupportedJsonSchemaTypeMessage = ( - input: string | UnsupportedJsonSchemaTypeMessageOptions +type UnjsonifiableExplanation = autocomplete<"morph" | "cyclic"> + +const writeUnjsonifiableMessage = ( + description: string, + explanation?: UnjsonifiableExplanation ): string => { - const normalized = typeof input === "string" ? { description: input } : input - let message = `${normalized.description} is not convertible to JSON Schema` - if (normalized.reason) message += ` because ${normalized.reason}` + let message = `${description} is not convertible to JSON Schema` + + if (explanation) { + const normalizedExplanation = + isKeyOf(explanation, unjsonifiableExplanations) ? + unjsonifiableExplanations[explanation] + : explanation + message += ` because ${normalizedExplanation}` + } + return message } -export const writeJsonSchemaMorphMessage = (description: string): string => - writeUnsupportedJsonSchemaTypeMessage({ - description: `Morph ${description}`, - reason: - "it represents a transformation, while JSON Schema only allows validation. Consider creating a Schema from one of its endpoints using `.in` or `.out`." - }) - -export const writeCyclicJsonSchemaMessage = (description: string): string => - writeUnsupportedJsonSchemaTypeMessage({ - description, - reason: - "cyclic types are not yet convertible to JSON Schema. If this feature is important to you, please add your feedback at https://github.com/arktypeio/arktype/issues/1087" - }) - -export const throwInternalJsonSchemaOperandError = ( - kind: ConstraintKind, - schema: JsonSchema -): never => - throwInternalError( - `Unexpected JSON Schema input for ${kind}: ${printable(schema)}` - ) +export const JsonSchema = { + writeUnjsonifiableMessage, + UnjsonifiableError: class UnjsonifiableError extends Error {}, + throwUnjsonifiableError: ( + ...args: Parameters + ): never => throwError(writeUnjsonifiableMessage(...args)), + throwInternalOperandError: ( + kind: ConstraintKind, + schema: JsonSchema + ): never => + throwInternalError( + `Unexpected JSON Schema input for ${kind}: ${printable(schema)}` + ) +} diff --git a/ark/schema/structure/required.ts b/ark/schema/structure/required.ts index 920125405d..50f34f7228 100644 --- a/ark/schema/structure/required.ts +++ b/ark/schema/structure/required.ts @@ -6,6 +6,7 @@ import { type nodeImplementationOf } from "../shared/implement.ts" import { BaseProp, intersectProps, type Prop } from "./prop.ts" + export declare namespace Required { export interface ErrorContext extends BaseErrorContext<"required"> { missingValueDescription: string diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index 34d68d3201..8af90a3603 100644 --- a/ark/schema/structure/sequence.ts +++ b/ark/schema/structure/sequence.ts @@ -34,10 +34,7 @@ import { type nodeImplementationOf } from "../shared/implement.ts" import { intersectOrPipeNodes } from "../shared/intersections.ts" -import { - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark, registeredReference } from "../shared/registry.ts" import { traverseKey, @@ -49,6 +46,7 @@ import { computeDefaultValueMorphs } from "./optional.ts" import { writeDefaultIntersectionMessage } from "./prop.ts" + export declare namespace Sequence { export interface NormalizedSchema extends BaseNormalizedSchema { readonly prefix?: array @@ -483,10 +481,8 @@ export class SequenceNode extends BaseConstraint { schema.prefixItems = this.prefix.map(node => node.toJsonSchema()) if (this.optionals) { - throwParseError( - writeUnsupportedJsonSchemaTypeMessage( - `Optional tuple element${this.optionalsLength > 1 ? "s" : ""} ${this.optionals.join(", ")}` - ) + return JsonSchema.throwUnjsonifiableError( + `Optional tuple element${this.optionalsLength > 1 ? "s" : ""} ${this.optionals.join(", ")}` ) } @@ -505,10 +501,8 @@ export class SequenceNode extends BaseConstraint { } if (this.postfix) { - throwParseError( - writeUnsupportedJsonSchemaTypeMessage( - `Postfix tuple element${this.postfixLength > 1 ? "s" : ""} ${this.postfix.join(", ")}` - ) + return JsonSchema.throwUnjsonifiableError( + `Postfix tuple element${this.postfixLength > 1 ? "s" : ""} ${this.postfix.join(", ")}` ) } diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index e3dea2ebeb..8fe38b1a30 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -27,11 +27,7 @@ import { type StructuralKind } from "../shared/implement.ts" import { intersectNodesRoot } from "../shared/intersections.ts" -import { - throwInternalJsonSchemaOperandError, - writeUnsupportedJsonSchemaTypeMessage, - type JsonSchema -} from "../shared/jsonSchema.ts" +import { JsonSchema } from "../shared/jsonSchema.ts" import { $ark, registeredReference, @@ -56,6 +52,13 @@ import type { Required } from "./required.ts" import type { Sequence } from "./sequence.ts" import { arrayIndexMatcherReference } from "./shared.ts" +/** + * `"ignore"` (default) - allow and preserve extra properties + * + * `"reject"` - disallow extra properties + * + * `"delete"` - clone and remove extra properties from output + */ export type UndeclaredKeyBehavior = "ignore" | UndeclaredKeyHandling export type UndeclaredKeyHandling = "reject" | "delete" @@ -680,15 +683,13 @@ export class StructureNode extends BaseConstraint { return this.reduceObjectJsonSchema(schema) case "array": if (this.props.length || this.index) { - throwParseError( - writeUnsupportedJsonSchemaTypeMessage( - `Additional properties on array ${this.expression}` - ) + return JsonSchema.throwUnjsonifiableError( + `Additional properties on array ${this.expression}` ) } return this.sequence?.reduceJsonSchema(schema) ?? schema default: - return throwInternalJsonSchemaOperandError("structure", schema) + return JsonSchema.throwInternalOperandError("structure", schema) } } @@ -697,10 +698,8 @@ export class StructureNode extends BaseConstraint { schema.properties = {} this.props.forEach(prop => { if (typeof prop.key === "symbol") { - return throwParseError( - writeUnsupportedJsonSchemaTypeMessage( - `Sybolic key ${prop.serializedKey}` - ) + return JsonSchema.throwUnjsonifiableError( + `Sybolic key ${prop.serializedKey}` ) } @@ -715,10 +714,8 @@ export class StructureNode extends BaseConstraint { return (schema.additionalProperties = index.value.toJsonSchema()) if (!index.signature.extends($ark.intrinsic.string)) { - return throwParseError( - writeUnsupportedJsonSchemaTypeMessage( - `Symbolic index signature ${index.signature.exclude($ark.intrinsic.string)}` - ) + return JsonSchema.throwUnjsonifiableError( + `Symbolic index signature ${index.signature.exclude($ark.intrinsic.string)}` ) } @@ -727,12 +724,11 @@ export class StructureNode extends BaseConstraint { !keyBranch.hasKind("intersection") || keyBranch.pattern?.length !== 1 ) { - return throwParseError( - writeUnsupportedJsonSchemaTypeMessage( - `Index signature ${keyBranch}` - ) + return JsonSchema.throwUnjsonifiableError( + `Index signature ${keyBranch}` ) } + schema.patternProperties ??= {} schema.patternProperties[keyBranch.pattern[0].rule] = index.value.toJsonSchema() diff --git a/ark/type/__tests__/config.test.ts b/ark/type/__tests__/config.test.ts index 16ea0f82ff..8300db7c72 100644 --- a/ark/type/__tests__/config.test.ts +++ b/ark/type/__tests__/config.test.ts @@ -88,7 +88,7 @@ contextualize(() => { }) attest(customEven.infer) attest(customEven(3).toString()).snap( - "custom message custom problem custom expected a multiple of 2 custom actual 3" + "custom message custom problem custom expected even custom actual 3" ) }) diff --git a/ark/type/__tests__/divisor.test.ts b/ark/type/__tests__/divisor.test.ts index 8f1270daaf..4809c5ca26 100644 --- a/ark/type/__tests__/divisor.test.ts +++ b/ark/type/__tests__/divisor.test.ts @@ -29,10 +29,16 @@ contextualize(() => { const expected = type("number%8").and("7 { + const n = type("0 < number <= 100") + + attest(n.description).snap("positive and at most 100") + }) + it("allows non-narrowed divisor", () => { const d = 5 as number attest(type(`number%${d}`).infer) diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index 1363ef84d3..6be3ea5ca9 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -136,7 +136,7 @@ tags must be at least length 3 (was 2)`) attest(out.toString()).snap(`email must be an email address (was "") extra must be a string or null (was missing) -score must be at least 0 (was -1) +score must be non-negative (was -1) tags must be at least length 3 (was 2) date must be a Date (was undefined) nospace must be matched by ^\\S*$ (was "One space")`) diff --git a/ark/type/__tests__/submodule.test.ts b/ark/type/__tests__/submodule.test.ts index bae6df9b2b..a9dd271f26 100644 --- a/ark/type/__tests__/submodule.test.ts +++ b/ark/type/__tests__/submodule.test.ts @@ -220,12 +220,12 @@ contextualize.each( }) attest(rootScope).type.toString.snap(`Scope<{ + group: { name: string }[] user: Submodule<{ root: { name: string } admin: { name: string; isAdmin: true } saiyan: { name: string; powerLevel: number } }> - group: { name: string }[] elevatedUser: | { name: string; isAdmin: true } | { name: string; powerLevel: number } diff --git a/ark/type/__tests__/thunk.test.ts b/ark/type/__tests__/thunk.test.ts index 559ace33c6..e7707a95c5 100644 --- a/ark/type/__tests__/thunk.test.ts +++ b/ark/type/__tests__/thunk.test.ts @@ -179,7 +179,9 @@ contextualize(() => { type({ inelegantKey: () => type("'inelegant value'") }) ) - attest(myInelegantType.t).type.toString.snap() + attest(myInelegantType.t).type.toString.snap( + '{ inelegantKey: "inelegant value" }' + ) attest(myInelegantType.expression).snap( '{ inelegantKey: "inelegant value" }' ) diff --git a/ark/type/__tests__/traverse.test.ts b/ark/type/__tests__/traverse.test.ts index e502c0c4b1..be94f319a4 100644 --- a/ark/type/__tests__/traverse.test.ts +++ b/ark/type/__tests__/traverse.test.ts @@ -5,7 +5,7 @@ contextualize(() => { it("divisible", () => { const t = type("number%2") attest(t(4)).snap(4) - attest(t(5).toString()).snap("must be a multiple of 2 (was 5)") + attest(t(5).toString()).snap("must be even (was 5)") }) it("range", () => { @@ -143,15 +143,14 @@ contextualize(() => { const naturalNumber = type("number.integer>0") attest(naturalNumber(-1.2).toString()).snap(`(-1.2) must be... • an integer - • more than 0`) + • positive`) const naturalAtPath = type({ natural: naturalNumber }) - attest(naturalAtPath({ natural: -0.1 }).toString()).snap( - `natural (-0.1) must be... + attest(naturalAtPath({ natural: -0.1 }).toString()) + .snap(`natural (-0.1) must be... • an integer - • more than 0` - ) + • positive`) }) it("homepage example", () => { @@ -234,7 +233,7 @@ age must be more than 18 (was 2)`) let callCount = 0 const t = type({ foo: ["unknown", "=>", () => callCount++] - }).satisfying((data, ctx) => ctx.mustBe("valid")) + }).filter((data, ctx) => ctx.mustBe("valid")) attest(t.t).type.toString.snap("{ foo: (In: unknown) => Out }") const out = t({ foo: 1 }) diff --git a/ark/type/__tests__/type.test.ts b/ark/type/__tests__/type.test.ts index 945dd7fe17..f4282f58da 100644 --- a/ark/type/__tests__/type.test.ts +++ b/ark/type/__tests__/type.test.ts @@ -22,6 +22,12 @@ contextualize(() => { attest(t.allows(5)).equals(false) }) + it("allows doc example", () => { + const numeric = type("number | bigint") + const numerics = [0, "one", 2n].filter(numeric.allows) + attest(numerics).snap([0, 2n]) + }) + it("errors can be thrown", () => { const t = type("number") try { diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 42187d00da..dcca3ad1d1 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -6,7 +6,6 @@ import type { MetaSchema, Morph, Predicate, - PredicateCast, StandardSchemaV1, UndeclaredKeyBehavior } from "@ark/schema" @@ -38,77 +37,233 @@ import type { instantiateType } from "./instantiate.ts" /** @ts-ignore cast variance */ interface Type extends Callable<(data: unknown) => distill.Out | ArkErrors> { + internal: BaseRoot [inferred]: t - // The top-level generic parameter accepted by the `Type`. Potentially - // includes morphs and subtype constraints not reflected in the types - // fully-inferred input (via `inferIn`) or output (via `infer` or - // `inferOut`) + /** + * The precompiled JS used to optimize validation. + * Will be `undefined` in [jitless](https://arktype.io/docs/configuration#jitless) mode. + */ + precompilation: string | undefined + + /** + * The generic parameter representing this Type + * + * @typeonly + * + * - ⚠️ May contain types representing morphs or default values that would + * be inaccurate if used directly for runtime values. In those cases, + * you should use {@link infer} or {@link inferIn} on this object instead. + */ t: t - // A type representing the output the `Type` will return (after morphs are - // applied to valid input) + /** + * The {@link Scope} in which definitions for this Type its chained methods are parsed + * @api Type + */ + $: Scope<$> + + /** + * The type of data this returns + * + * @typeonly + * + * @example + * const parseNumber = type("string").pipe(s => Number.parseInt(s)) + * type ParsedNumber = typeof parseNumber.infer // number + * + * @api Type + */ infer: this["inferOut"] - inferIntrospectableOut: distill.introspectable.Out + /** + * Alias of {@link infer} + * + * @typeonly + * + * @example + * const parseNumber = type("string").pipe(s => Number.parseInt(s)) + * type ParsedNumber = typeof parseNumber.infer // number + */ inferOut: distill.Out - // A type representing the input the `Type` will accept (before morphs are applied) - // @example export type MyTypeInput = typeof MyType.inferIn + + /** + * The type of output that can be introspected at runtime (e.g. via {@link out}) + * + * - If your Type contains morphs, they will be inferred as `unknown` unless + * they are an ArkType keyword or have an explicitly defined output validator. + * @typeonly + * + * @example + * const unmorphed = type("string") + * // with no morphs, we can introspect the input and output as a single Type + * type UnmorphedOut = typeof unmorphed.inferIntrospectableOut // string + * + * const morphed = type("string").pipe(s => s.length) + * // with a standard user-defined morph, TypeScript can infer a + * // return type from your function, but we have no way to + * // know the shape at runtime + * type MorphOut = typeof morphed.inferIntrospectableOut // unknown + * + * const validated = type("string").pipe(s => s.length).to("number") + * // morphs with validated output, including all morph keywords, are introspectable + * type ValidatedMorphOut = typeof validated.inferIntrospectableOut + */ + inferIntrospectableOut: distill.introspectable.Out + + /** + * The type of data this expects + * + * @typeonly + * + * @example + * const parseNumber = type("string").pipe(s => Number.parseInt(s)) + * type UnparsedNumber = typeof parseNumber.inferIn // string + * @api Type + */ inferIn: distill.In - inferredOutIsIntrospectable: t extends InferredMorph ? - [o] extends [anyOrNever] ? true - : o extends To ? true - : false - : // special-case unknown here to preserve assignability - unknown extends t ? boolean - : true - - /** Internal JSON representation of this `Type` */ + + /** + * The internal JSON representation + * @api Type + */ json: JsonStructure + + /** + * Alias of {@link json} for `JSON.stringify` compatibility + */ toJSON(): JsonStructure - meta: ArkAmbient.meta - precompilation: string | undefined + + /** + * Generate a JSON Schema + * @throws {JsonSchema.UnjsonifiableError} if this cannot be converted to JSON Schema + * @api Type + */ toJsonSchema(): JsonSchema + + /** + * Metadata like custom descriptions and error messages + * + * @description The type of this property {@link https://arktype.io/docs/configuration#custom | can be extended} by your project. + * @api Type + */ + meta: ArkAmbient.meta + + /** + * An English description + * + * - Work best for primitive values + * + * @example + * const n = type("0 < number <= 100") + * console.log(n.description) // positive and at most 100 + * + * @api Type + */ description: string + + /** + * A syntax string similar to native TypeScript + * + * - Works well for both primitives and structures + * + * @example + * const loc = type({ coords: ["number", "number"] }) + * console.log(loc.expression) // { coords: [number, number] } + * + * @api Type + */ expression: string - internal: BaseRoot - $: Scope<$> /** - * Validate data, throwing `type.errors` instance on failure - * @returns a valid value - * @example const validData = T.assert(rawData) + * Validate and morph data, throwing a descriptive AggregateError on failure + * + * - Sugar to avoid checking for {@link type.errors} if they are unrecoverable + * + * @example + * const criticalPayload = type({ + * superImportantValue: "string" + * }) + * // throws AggregateError: superImportantValue must be a string (was missing) + * const data = criticalPayload.assert({ irrelevantValue: "whoops" }) + * console.log(data.superImportantValue) // valid output can be accessed directly + * + * @throws {AggregateError} + * @api Type */ assert(data: unknown): this["infer"] /** - * Check if data matches the input shape. - * Doesn't process any morphs, but does check narrows. - * @example type({ foo: "number" }).allows({ foo: "bar" }) // false + * Validate input data without applying morphs + * + * - Good for cases like filtering that don't benefit from detailed errors + * + * @example + * const numeric = type("number | bigint") + * // [0, 2n] + * const numerics = [0, "one", 2n].filter(numeric.allows) + * + * @api Type */ allows(data: unknown): data is this["inferIn"] - traverse(data: unknown): this["infer"] | ArkErrors - + /** + * Clone and add metadata to shallow references + * + * - Does not affect error messages within properties of an object + * - Overlapping keys on existing meta will be overwritten + * + * @example + * const notOdd = type("number % 2").configure({ description: "not odd" }) + * // all constraints at the root are affected + * const odd = notOdd(3) // must be not odd (was 3) + * const nonNumber = notOdd("two") // must be not odd (was "two") + * + * const notOddBox = type({ + * // we should have referenced notOdd or added meta here + * notOdd: "number % 2", + * // but instead chained from the root object + * }).configure({ description: "not odd" }) + * // error message at path notOdd is not affected + * const oddProp = notOddBox({ notOdd: 3 }) // notOdd must be even (was 3) + * // error message at root is affected, leading to a misleading description + * const nonObject = notOddBox(null) // must be not odd (was null) + * + * @api Type + */ configure(meta: MetaSchema): this + /** + * Clone and add the description to shallow references + * + * - Equivalent to `.configure({ description })` (see {@link configure}) + * - Does not affect error messages within properties of an object + * + * @example + * const aToZ = type(/^a.*z$/).describe("a string like 'a...z'") + * const good = aToZ("alcatraz") // "alcatraz" + * // notice how our description is integrated with other parts of the message + * const badPattern = aToZ("albatross") // must be a string like 'a...z' (was "albatross") + * const nonString = aToZ(123) // must be a string like 'a...z' (was 123) + * + * @api Type + */ describe(description: string): this /** - * Create a copy of this `Type` with updated unknown key behavior - * - `ignore`: ignore unknown properties (default) - * - 'reject': disallow objects with unknown properties - * - 'delete': clone the object and keep only known properties + * Clone to a new Type with the specified undeclared key behavior. + * + * {@inheritDoc UndeclaredKeyBehavior} + * @api Type */ onUndeclaredKey(behavior: UndeclaredKeyBehavior): this /** - * Create a copy of this `Type` with updated unknown key behavior\ - * The behavior applies to the whole object tree, not just the immediate properties. - * - `ignore`: ignore unknown properties (default) - * - 'reject': disallow objects with unknown properties - * - 'delete': clone the object and keep only known properties - */ + * Deeply clone to a new Type with the specified undeclared key behavior. + * + * {@inheritDoc UndeclaredKeyBehavior} + * @api Type + **/ onDeepUndeclaredKey(behavior: UndeclaredKeyBehavior): this /** @@ -117,23 +272,12 @@ interface Type */ from(literal: this["inferIn"]): this["infer"] - /** - * Cast the way this `Type` is inferred (has no effect at runtime). - * const branded = type(/^a/).as<`a${string}`>() // Type<`a${string}`> - */ - as( - ...args: validateChainedAsArgs - ): instantiateType - - brand>( - name: name - ): instantiateType - /** * A `Type` representing the deeply-extracted input of the `Type` (before morphs are applied). * @example const inputT = T.in */ get in(): instantiateType + /** * A `Type` representing the deeply-extracted output of the `Type` (after morphs are applied).\ * **IMPORTANT**: If your type includes morphs, their output will likely be unknown @@ -142,17 +286,17 @@ interface Type */ get out(): instantiateType - // inferring r into an alias improves perf and avoids return type inference - // that can lead to incorrect results. See: - // https://discord.com/channels/957797212103016458/1285420361415917680/1285545752172429312 /** - * Intersect another `Type` definition, returning an introspectable `Disjoint` if the result is unsatisfiable. - * @example const intersection = type({ foo: "number" }).intersect({ bar: "string" }) // Type<{ foo: number; bar: string }> - * @example const intersection = type({ foo: "number" }).intersect({ foo: "string" }) // Disjoint + * Cast the way this `Type` is inferred (has no effect at runtime). + * const branded = type(/^a/).as<`a${string}`>() // Type<`a${string}`> */ - intersect>( - def: type.validate - ): instantiateType, $> | Disjoint + as( + ...args: validateChainedAsArgs + ): instantiateType + + brand>( + name: name + ): instantiateType /** * Intersect another `Type` definition, throwing an error if the result is unsatisfiable. @@ -172,6 +316,35 @@ interface Type def: type.validate ): instantiateType + /** + * Create a `Type` for array with elements of this `Type` + * @example const T = type(/^foo/); const array = T.array() // Type + */ + array(): ArrayType + + optional(): [this, "?"] + + /** + * Add a default value for this `Type` when it is used as a property.\ + * Default value should be a valid input value for this `Type, or a function that returns a valid input value.\ + * If the type has a morph, it will be applied to the default value. + * @example const withDefault = type({ foo: type("string").default("bar") }); withDefault({}) // { foo: "bar" } + * @example const withFactory = type({ foo: type("number[]").default(() => [1])) }); withFactory({baz: 'a'}) // { foo: [1], baz: 'a' } + * @example const withMorph = type({ foo: type("string.numeric.parse").default("123") }); withMorph({}) // { foo: 123 } + */ + default>( + value: value + ): [this, "=", value] + + filter< + narrowed extends this["inferIn"] = never, + r = [narrowed] extends [never] ? t + : t extends InferredMorph ? (In: narrowed) => o + : narrowed + >( + predicate: Predicate.Castable + ): instantiateType + /** * Add a custom predicate to this `Type`. * @example const nan = type('number').narrow(n => Number.isNaN(n)) // Type @@ -187,32 +360,27 @@ interface Type : (In: i) => Out : narrowed >( - predicate: Predicate | PredicateCast + predicate: Predicate.Castable ): instantiateType - satisfying< - narrowed extends this["inferIn"] = never, - r = [narrowed] extends [never] ? t - : t extends InferredMorph ? (In: narrowed) => o - : narrowed - >( - predicate: - | Predicate - | PredicateCast - ): instantiateType - - /** - * Create a `Type` for array with elements of this `Type` - * @example const T = type(/^foo/); const array = T.array() // Type - */ - array(): ArrayType - /** * Morph this `Type` through a chain of morphs. * @example const dedupe = type('string[]').pipe(a => Array.from(new Set(a))) * @example type({codes: 'string.numeric[]'}).pipe(obj => obj.codes).to('string.numeric.parse[]') */ - pipe: ChainedPipes + pipe: ChainedPipe + + // inferring r into an alias improves perf and avoids return type inference + // that can lead to incorrect results. See: + // https://discord.com/channels/957797212103016458/1285420361415917680/1285545752172429312 + /** + * Intersect another `Type` definition, returning an introspectable `Disjoint` if the result is unsatisfiable. + * @example const intersection = type({ foo: "number" }).intersect({ bar: "string" }) // Type<{ foo: number; bar: string }> + * @example const intersection = type({ foo: "number" }).intersect({ foo: "string" }) // Disjoint + */ + intersect>( + def: type.validate + ): instantiateType, $> | Disjoint equals(def: type.validate): boolean @@ -236,26 +404,27 @@ interface Type r: type.validate ): instantiateType, $> + traverse(data: unknown): this["infer"] | ArkErrors + + /** + * @experimental + * Map and optionally reduce branches of a union. Types that are not unions + * are treated as a single branch. + * + * @param mapBranch - the mapping function, accepting a branch Type + * Returning another `Type` is common, but any value can be returned and + * inferred as part of the output. + * + * @param [reduceMapped] - an operation to perform on the mapped branches + * Can be used to e.g. merge an array of returned Types representing + * branches back to a single union. + */ distribute( mapBranch: (branch: Type, i: number, branches: array) => mapOut, reduceMapped?: (mappedBranches: mapOut[]) => reduceOut ): reduceOut - optional(): [this, "?"] - - /** - * Add a default value for this `Type` when it is used as a property.\ - * Default value should be a valid input value for this `Type, or a function that returns a valid input value.\ - * If the type has a morph, it will be applied to the default value. - * @example const withDefault = type({ foo: type("string").default("bar") }); withDefault({}) // { foo: "bar" } - * @example const withFactory = type({ foo: type("number[]").default(() => [1])) }); withFactory({baz: 'a'}) // { foo: [1], baz: 'a' } - * @example const withMorph = type({ foo: type("string.numeric.parse").default("123") }); withMorph({}) // { foo: 123 } - */ - default>( - value: value - ): [this, "=", value] - - // Standard Schema Compatibility (https://github.com/standard-schema/standard-schema) + /** The Type's [StandardSchema](https://github.com/standard-schema/standard-schema) properties */ "~standard": StandardSchemaV1.ArkTypeProps // deprecate Function methods so they are deprioritized as suggestions @@ -373,7 +542,7 @@ interface ChainedPipeSignature { ): NoInfer extends infer result ? result : never } -export interface ChainedPipes extends ChainedPipeSignature { +export interface ChainedPipe extends ChainedPipeSignature { try: ChainedPipeSignature } diff --git a/ark/type/package.json b/ark/type/package.json index 030927cec8..2410f2e6da 100644 --- a/ark/type/package.json +++ b/ark/type/package.json @@ -1,7 +1,7 @@ { "name": "arktype", "description": "TypeScript's 1:1 validator, optimized from editor to runtime", - "version": "2.0.0-rc.32", + "version": "2.0.0-rc.33", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/type/parser/string.ts b/ark/type/parser/string.ts index 877c50862d..3a80cb6561 100644 --- a/ark/type/parser/string.ts +++ b/ark/type/parser/string.ts @@ -25,12 +25,10 @@ export const parseString = ( const aliasResolution = ctx.$.maybeResolveRoot(def) if (aliasResolution) return aliasResolution - const aliasArrayResolution = - def.endsWith("[]") ? - ctx.$.maybeResolveRoot(def.slice(0, -2))?.array() - : undefined - - if (aliasArrayResolution) return aliasArrayResolution + if (def.endsWith("[]")) { + const possibleElementResolution = ctx.$.maybeResolveRoot(def.slice(0, -2)) + if (possibleElementResolution) return possibleElementResolution.array() + } const s = new DynamicState(new ArkTypeScanner(def), ctx) diff --git a/ark/type/type.ts b/ark/type/type.ts index 5f4ffbb44a..1b99bb3275 100644 --- a/ark/type/type.ts +++ b/ark/type/type.ts @@ -40,16 +40,36 @@ import type { ScopeParser, bindThis } from "./scope.ts" + /** The convenience properties attached to `type` */ export type TypeParserAttachments = // map over to remove call signatures Omit export interface TypeParser<$ = {}> extends Ark.boundTypeAttachments<$> { - // Parse and check the definition, returning either the original input for a - // valid definition or a string representing an error message. - , $>>(def: type.validate): r + /** + * Create a {@link Type} from your definition. + * + * @example const person = type({ name: "string" }) + */ + , $>>( + // Parse and check the definition, returning either the original input for a + // valid definition or a string representing an error message. + def: type.validate + ): r + /** + * Create a {@link Generic} from a parameter string and body definition. + * + * @param params A string like "" specifying the + * {@link Generic}'s parameters and any associated constraints via `extends`. + * + * @param def The definition for the body of the {@link Generic}. Can reference the + * parameter names specified in the previous argument in addition to aliases + * from its {@link Scope}. + * + * @example const boxOf = type("", { contents: "t" }) + */ ( params: validateParameterString, def: type.validate< @@ -59,7 +79,12 @@ export interface TypeParser<$ = {}> extends Ark.boundTypeAttachments<$> { > ): Generic, def, $> - // Spread version of a tuple expression + /** + * Create a {@link Type} from a [tuple expression](http://localhost:3000/docs/expressions) + * spread as this function's arguments. + * + * @example type("string", "|", { foo: "number" }) + */ < const zero, const one, @@ -82,14 +107,27 @@ export interface TypeParser<$ = {}> extends Ark.boundTypeAttachments<$> { ): r /** - * Error class for validation errors - * Calling type instance returns an instance of this class on failure - * @example if ( T(data) instanceof type.errors ) { ... } + * An alias of the {@link ArkErrors} class, an instance of which is returned when a {@link Type} + * is invoked with invalid input. + * + * @example + * const out = myType(data) + * + * if(out instanceof type.errors) console.log(out.summary) + * */ errors: typeof ArkErrors hkt: typeof Hkt keywords: typeof keywords + /** + * The {@link Scope} in which definitions passed to this function will be parsed. + */ $: Scope<$> + /** + * An alias of `type` with no type-level validation or inference. + * + * Useful when wrapping `type` or using it to parse a dynamic definition. + */ raw(def: unknown): BaseType module: ModuleParser scope: ScopeParser @@ -97,20 +135,20 @@ export interface TypeParser<$ = {}> extends Ark.boundTypeAttachments<$> { generic: GenericParser<$> schema: SchemaParser<$> /** - * Create a `Type` that is satisfied only by a value strictly equal (`===`) to the argument passed to this function. - * @example const foo = type.unit('foo') // Type<'foo'> - * @example const sym: unique symbol = Symbol(); type.unit(sym) // Type + * Create a {@link Type} that is satisfied only by a value strictly equal (`===`) to the argument passed to this function. + * @example const foo = type.unit('foo') // {@link Type}<'foo'> + * @example const sym: unique symbol = Symbol(); type.unit(sym) // {@link Type} */ unit: UnitTypeParser<$> /** - * Create a `Type` that is satisfied only by a value strictly equal (`===`) to one of the arguments passed to this function. + * Create a {@link Type} that is satisfied only by a value strictly equal (`===`) to one of the arguments passed to this function. * @example const enum = type.enumerated('foo', 'bar', obj) // obj is a by-reference object * @example const tupleForm = type(['===', 'foo', 'bar', obj]) * @example const argsForm = type('===', 'foo', 'bar', obj) */ enumerated: EnumeratedTypeParser<$> /** - * Create a `Type` that is satisfied only by a value of a specific class. + * Create a {@link Type} that is satisfied only by a value of a specific class. * @example const array = type.instanceOf(Array) */ instanceOf: InstanceOfTypeParser<$> diff --git a/ark/util/__tests__/flatMorph.test.ts b/ark/util/__tests__/flatMorph.test.ts index 87a2a3a4ae..9664725899 100644 --- a/ark/util/__tests__/flatMorph.test.ts +++ b/ark/util/__tests__/flatMorph.test.ts @@ -82,4 +82,17 @@ contextualize(() => { ) attest<(1 | 3)[]>(result).equals([1, 3]) }) + + it("groupable", () => { + const result = flatMorph({ a: true, b: false, c: 0, d: 1 }, (k, v) => + typeof v === "boolean" ? + ([{ group: "bools" }, v] as const) + : ([{ group: "nums" }, v] as const) + ) + + attest<{ + bools: boolean[] + nums: (0 | 1)[] + }>(result).snap({ bools: [true, false], nums: [0, 1] }) + }) }) diff --git a/ark/util/arrays.ts b/ark/util/arrays.ts index 1b9a08fa58..1096287c6d 100644 --- a/ark/util/arrays.ts +++ b/ark/util/arrays.ts @@ -116,7 +116,6 @@ export const liftArray = (data: t): liftArray => /** * Splits an array into two arrays based on the result of a predicate * - * @param arr - The input array to be split. * @param predicate - The guard function used to determine which items to include. * @returns A tuple containing two arrays: * - the first includes items for which `predicate` returns true @@ -160,17 +159,12 @@ export const range = (length: number, offset = 0): number[] => [...new Array(length)].map((_, i) => i + offset) export type AppendOptions = { + /** If true, adds the element to the beginning of the array instead of the end */ prepend?: boolean } /** * Adds a value or array to an array, returning the concatenated result - * - * @param to The array to which `value` is to be added. If `to` is `undefined`, a new array - * is created as `[value]` if value was not undefined, otherwise `[]`. - * @param value The value to add to the array. - * @param opts - * prepend: If true, adds the element to the beginning of the array instead of the end */ export const append = < to extends unknown[] | undefined, @@ -208,9 +202,6 @@ export type appendableValue = /** * Concatenates an element or list with a readonly list - * - * @param {to} to - The base list. - * @param {elementOrList} elementOrList - The element or list to concatenate. */ export const conflatenate = ( to: readonly element[] | undefined | null, @@ -226,8 +217,6 @@ export const conflatenate = ( /** * Concatenates a variadic list of elements or lists with a readonly list - * - * @param {elementsOrLists} elementsOrLists - The elements or lists to concatenate. */ export const conflatenateAll = ( ...elementsOrLists: (listable | undefined | null)[] @@ -240,10 +229,6 @@ export interface ComparisonOptions { /** * Appends a value or concatenates an array to an array if it is not already included, returning the array - * - * @param to The array to which `value` is to be appended. If `to` is `undefined`, a new array - * is created including only `value`. - * @param value An array or value to append to the array. If `to` includes `value`, nothing is appended. */ export const appendUnique = ( to: to | undefined, diff --git a/ark/util/clone.ts b/ark/util/clone.ts index 75a1d2bade..32e6f63100 100644 --- a/ark/util/clone.ts +++ b/ark/util/clone.ts @@ -5,13 +5,7 @@ export const shallowClone: ( input: input ) => input = input => _clone(input, null) -/** Deeply copy the properties of the a non-subclassed Object, Array or Date. - * - * @param input The object to clone - * - * @returns A new deeply cloned version of the object, or the original object - * if it has a prototype other than Object, Array Date, or null. - */ +/** Deeply copy the properties of the a non-subclassed Object, Array or Date.*/ export const deepClone = (input: input): input => _clone(input, new Map()) diff --git a/ark/util/flatMorph.ts b/ark/util/flatMorph.ts index 34f66e1816..7f2e86c88e 100644 --- a/ark/util/flatMorph.ts +++ b/ark/util/flatMorph.ts @@ -1,12 +1,17 @@ -import type { array, listable } from "./arrays.ts" -import type { show } from "./generics.ts" +import { append, type array, type listable } from "./arrays.ts" +import type { conform, show } from "./generics.ts" import type { Key } from "./keys.ts" -import type { Entry, entryOf, fromEntries } from "./records.ts" +import type { Entry, entryOf } from "./records.ts" import type { intersectUnion } from "./unionToTuple.ts" -type objectFromListableEntries = show< - intersectUnion> -> +type objectFromListableEntries = + show>> + +type fromGroupableEntries = { + [entry in entries[number] as entry extends GroupedEntry ? entry[0]["group"] + : conform]: entry extends GroupedEntry ? entry[1][] + : entry[1] +} type arrayFromListableEntries = Entry extends transformed ? transformed[1][] @@ -27,8 +32,8 @@ type _arrayFromListableEntries< : never : [...result, ...transformed[1][]] -type extractEntrySets> = - e extends readonly Entry[] ? e : [e] +type extractEntrySets> = + e extends readonly GroupableEntry[] ? e : [e] type extractEntries> = e extends readonly Entry[] ? e[number] : e @@ -43,25 +48,29 @@ type numericArrayEntry = [i in keyof a]: i extends `${infer n extends number}` ? [n, a[i]] : never }[number] -export type MappedEntry = listable | Entry> +export type GroupedEntry = readonly [key: { group: Key }, value: unknown] + +export type GroupableEntry = Entry | Entry | GroupedEntry -export type fromMappedEntries = +export type ListableEntry = listable + +export type fromMappedEntries = [transformed] extends [listable>] ? arrayFromListableEntries> : objectFromListableEntries> export type FlatMorph = { - ( + ( o: o, flatMapEntry: (...args: numericArrayEntry) => transformed ): fromMappedEntries - ( + ( o: o, flatMapEntry: (...args: entryOf) => transformed ): fromMappedEntries - ( + ( o: o, flatMapEntry: (...args: entryArgsWithIndex) => transformed ): fromMappedEntries @@ -69,24 +78,32 @@ export type FlatMorph = { export const flatMorph: FlatMorph = ( o: object, - flatMapEntry: (...args: any[]) => listable + flatMapEntry: (...args: any[]) => listable ): any => { + const result: any = {} const inputIsArray = Array.isArray(o) - const entries: Entry[] = Object.entries(o).flatMap((entry, i) => { - const result = + let outputShouldBeArray = false + + Object.entries(o).forEach((entry, i) => { + const mapped = inputIsArray ? flatMapEntry(i, entry[1]) : flatMapEntry(...entry, i) - const entrySet = - Array.isArray(result[0]) || result.length === 0 ? + + outputShouldBeArray ||= typeof mapped[0] === "number" + + const flattenedEntries = + Array.isArray(mapped[0]) || mapped.length === 0 ? // if we have an empty array (for filtering) or an array with - // another array as its first element, treat it as a list of - (result as Entry[]) + // another array as its first element, treat it as a list + (mapped as GroupableEntry[]) // otherwise, it should be a single entry, so nest it in a tuple // so it doesn't get spread when the result is flattened - : [result as Entry] - return entrySet + : [mapped as GroupableEntry] + + flattenedEntries.forEach(([k, v]) => { + if (typeof k === "object") result[k.group] = append(result[k.group], v) + else result[k] = v + }) }) - const objectResult = Object.fromEntries(entries) - return typeof entries[0]?.[0] === "number" ? - Object.values(objectResult) - : objectResult + + return outputShouldBeArray ? Object.values(result) : result } diff --git a/ark/util/numbers.ts b/ark/util/numbers.ts index 4ba8a89800..d9c7dc8871 100644 --- a/ark/util/numbers.ts +++ b/ark/util/numbers.ts @@ -233,9 +233,7 @@ export const tryParseWellFormedBigint = (def: string): bigint | undefined => { /** * Returns the next or previous representable floating-point number after the given input. * - * @param {number} n - The input number. * @param {"+" | "-"} [direction="+"] - The direction to find the nearest float. "+" for the next float, "-" for the previous float. - * @returns {number} The nearest representable floating-point number after or before the input. * @throws {Error} If the input is not a finite number. * * @example diff --git a/ark/util/objectKinds.ts b/ark/util/objectKinds.ts index de61363510..c86c7f55af 100644 --- a/ark/util/objectKinds.ts +++ b/ark/util/objectKinds.ts @@ -268,9 +268,6 @@ export type instanceOf = /** * Returns an array of constructors for all ancestors (i.e., prototypes) of a given object. - * - * @param {object} o - The object to find the ancestors of. - * @returns {Function[]} An array of constructors for all ancestors of the object. */ export const ancestorsOf = (o: object): Function[] => { let proto = Object.getPrototypeOf(o) diff --git a/ark/util/package.json b/ark/util/package.json index df1e584caa..79870a8938 100644 --- a/ark/util/package.json +++ b/ark/util/package.json @@ -1,6 +1,6 @@ { "name": "@ark/util", - "version": "0.32.0", + "version": "0.33.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/util/records.ts b/ark/util/records.ts index b8ba233e8f..13559124f9 100644 --- a/ark/util/records.ts +++ b/ark/util/records.ts @@ -92,9 +92,6 @@ export type entriesOf = entryOf[] /** * Object.entries wrapper providing narrowed types for objects with known sets * of keys, e.g. those defined internally as configs - * - * @param o the object to get narrowed entries from - * @returns a narrowed array of entries based on that object's type */ export const entriesOf: (o: o) => entryOf[] = Object.entries as never diff --git a/ark/util/registry.ts b/ark/util/registry.ts index fe712d0cf1..f350e70e7f 100644 --- a/ark/util/registry.ts +++ b/ark/util/registry.ts @@ -7,7 +7,7 @@ import { FileConstructor, objectKindOf } from "./objectKinds.ts" // recent node versions (https://nodejs.org/api/esm.html#json-modules). // For now, we assert this matches the package.json version via a unit test. -export const arkUtilVersion = "0.32.0" +export const arkUtilVersion = "0.33.0" export const initialRegistryContents = { version: arkUtilVersion, diff --git a/ark/util/serialize.ts b/ark/util/serialize.ts index 470b040cc5..c4572499a2 100644 --- a/ark/util/serialize.ts +++ b/ark/util/serialize.ts @@ -118,6 +118,8 @@ const _serialize = ( return opts.onBigInt?.(data as bigint) ?? `${data}n` case "undefined": return opts.onUndefined ?? "undefined" + case "string": + return (data as string).replaceAll("\\", "\\\\") default: return data } @@ -125,9 +127,6 @@ const _serialize = ( /** * Converts a Date instance to a human-readable description relative to its precision - * - * @param {Date} date - * @returns {string} - The generated description */ export const describeCollapsibleDate = (date: Date): string => { const year = date.getFullYear() diff --git a/package.json b/package.json index 8aff76c9cf..dcf4298dd5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "prChecks": "pnpm lint && pnpm buildRepo && pnpm testRepoWithVersionsAndBenches", "attest": "ts ./ark/attest/cli/cli.ts", "build": "pnpm -r --filter !'@ark/docs' build", - "buildRepo": "pnpm -r build", + "buildRepo": "pnpm rmBuild && pnpm build && pnpm buildDocs", "buildDocs": "pnpm -r --filter '@ark/docs' build", "buildCjs": "ARKTYPE_CJS=1 pnpm -r build", "rmBuild": "pnpm -r exec rm -rf out",