Skip to content

Commit

Permalink
docs: modules, visibility (#1244)
Browse files Browse the repository at this point in the history
  • Loading branch information
ssalbdivad authored Jan 3, 2025
1 parent 34c649a commit 0f5d566
Show file tree
Hide file tree
Showing 8 changed files with 403 additions and 13 deletions.
43 changes: 41 additions & 2 deletions ark/docs/content/docs/integrations/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,47 @@ t.procedure.input(

### react-hook-form

🚧 Coming soon ™️🚧
react-hook-form has builtin support for ArkType via [`@hookform/resolvers`](https://github.com/react-hook-form/resolvers/tree/master):

```ts
// @noErrors
import { useForm } from "react-hook-form"
import { arktypeResolver } from "@hookform/resolvers/arktype"
import { type } from "arktype"

const user = type({
firstName: "string",
age: "number.integer > 0"
})

// in your component
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
resolver: arktypeResolver(user)
})
```

### hono

🚧 Coming soon ™️🚧
Hono has builtin support for ArkType via [`@hono/arktype-validator`](https://github.com/honojs/middleware/tree/main/packages/arktype-validator):

```ts
// @noErrors
const user = type({
name: "string",
age: "number"
})

app.post("/author", arktypeValidator("json", user), c => {
const data = c.req.valid("json")
return c.json({
success: true,
message: `${data.name} is ${data.age}`
})
})
```

[`hono-openapi`](https://github.com/rhinobase/hono-openapi) also offers experimental support for OpenAPI docgen.
138 changes: 134 additions & 4 deletions ark/docs/content/docs/scopes/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
title: Scopes
---

<a id="intro" />

Scopes are the foundation of ArkType, and one of the most powerful features for users wanting full control over configuration and to make their own keywords available fluidly within string definition syntax.

A scope is just like a scope in code- a resolution space where you can define types, generics, or other scopes. The `type` export is a actually just a method on our default `Scope`!
Expand Down Expand Up @@ -189,19 +191,147 @@ const out = types.package(packageData)

<Callout type="warn" title="Some `any`s are not what they seem!">

By default, TypeScript represents anonymous cycles as `...`. However, if you have `noErrorTruncation` enabled, they will are visually displayed as `any`😬
By default, TypeScript represents anonymous cycles as `...`. However, if you have `noErrorTruncation` enabled, they are visually displayed as `any`😬

Luckily, despite its appearance, the type otherwise behaves as you'd expect. Rest assured, TypeScript will complain as normal if you access a non-existent property.
Luckily, despite its appearance, the type otherwise behaves as you'd expect- TypeScript will provide completions and will complain as normal if you access a non-existent property.

</Callout>

### visibility

🚧 Coming soon ™️🚧
Intermediate aliases can be useful for composing Scoped definitions from aliases. Sometimes, you may not want to expose those aliases externally as `Type`s when your `Scope` is `export`ed.

This can be done using _private_ aliases:

```ts
const shapeScope = scope({
// aliases with a "#" prefix are treated as private
"#baseShapeProps": {
perimeter: "number",
area: "number"
},
ellipse: {
// when referencing a private alias, the "#" should not be included
"...": "baseShapeProps",
radii: ["number", "number"]
},
rectangle: {
"...": "baseShapeProps",
width: "number",
height: "number"
}
})

// private aliases can be referenced from any scoped definition,
// even outside the original scope
const partialShape = shapeScope.type("Partial<baseShapeProps>")

// when the scope is exported to a Module, they will not be included
// hover to see the Scope's exports
const shapeModule = shapeScope.export()
```

#### `import()`

Private aliases are especially useful for building scopes without polluting them with every alias you might want to reference internally. To facilitate this, Scopes have an `import()` method that behaves identically to `export()` but converts all exported aliases to `private`.

```ts
const utilityScope = scope({
"withId<o extends object>": {
"...": "o",
id: "string"
}
})

const userModule = type.module({
// because we use `import()` here, we can reference our utilities
// internally, but they will not be included in `userModule`.
// if we used `export()` instead, `withId` could be accessed on `userModule`.
...utilityScope.import(),
payload: {
name: "string",
age: "number"
},
db: "withId<payload>"
})
```

### submodules

🚧 Coming soon ™️🚧
If you've used keywords like `string.email` or `number.integer`, you may wonder if aliases can be grouped in your own Scopes. Recall from [the introduction to Scopes](#intro) that `type` is actually just a method on ArkType's default `Scope`, meaning all of its functionality is available externally, including alias groups called _Submodules_.

Submodules are groups of aliases with a shared prefix. To define one, just assign the value of the prefix to a `Module` with the names you want:

```ts
const subAliases = type.module({ alias: "number" })

const rootScope = scope({
a: "string",
b: "sub.alias",
sub: subAliases
})

const myType = rootScope.type({
someKey: "sub.alias[]"
})
```

Submodules are parsed bottom-up. This means subaliases can be referenced directly in the root scope,
but root aliases can't be referenced from the submodule, even if it's inlined.

#### nested

Submodules can be nested to arbitrary depth:

```ts
const subAliases = type.module({ alias: "number" })

const rootScope = scope({
a: "string",
b: "sub.alias",
sub: subAliases
})
// ---cut---

const rootScopeSquared = scope({
// reference rootScope from our previous example
newRoot: rootScope.export()
})

const myNewType = rootScopeSquared.type({
someOtherKey: "newRoot.sub.alias | boolean"
})
```

#### rooted

The Submodules from our previous examples group `Type`s together, but cannot be referenced as `Type`s themselves the way `string` and `number` can. To define a _Rooted Submodule_, just use an alias called `root`:

```ts
const userModule = type.module({
root: {
name: "string"
},
// subaliases can extend a base type by referencing 'root'
// like any other alias
admin: {
"...": "root",
isAdmin: "true"
},
saiyan: {
"...": "root",
powerLevel: "number > 9000"
}
})

const rootModule = type.module({
user: userModule,
// user can now be referenced directly in a definition
group: "user[]",
// or used as a prefix to access subaliases
elevatedUser: "user.admin | user.saiyan"
})
```

### thunks

Expand Down
4 changes: 2 additions & 2 deletions ark/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@ark/util": "workspace:*",
"@fumadocs/cli": "0.0.6",
"@icons-pack/react-simple-icons": "10.2.0",
"@shikijs/transformers": "1.25.1",
"@shikijs/transformers": "1.26.1",
"@types/mdx": "2.0.13",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
Expand All @@ -38,7 +38,7 @@
"prettier-plugin-tailwindcss": "0.6.9",
"react": "19.0.0",
"react-dom": "19.0.0",
"shiki": "1.25.1",
"shiki": "1.26.1",
"tailwindcss": "3.4.17",
"twoslash": "0.2.12",
"typescript": "catalog:"
Expand Down
12 changes: 10 additions & 2 deletions ark/schema/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export abstract class BaseScope<$ extends {} = {}> {
readonly resolutions: {
[alias: string]: CachedResolution | undefined
} = {}
readonly json: JsonStructure = {}

exportedNames: string[] = []
readonly aliases: Record<string, unknown> = {}
protected resolved = false
Expand Down Expand Up @@ -218,6 +218,14 @@ export abstract class BaseScope<$ extends {} = {}> {
return this
}

// json is populated when the scope is exported, so ensure it is populated
// before allowing external access
private _json: JsonStructure | undefined
get json(): JsonStructure {
if (!this._json) this.export()
return this._json!
}

defineSchema<def extends RootSchema>(def: def): def {
return def
}
Expand Down Expand Up @@ -485,7 +493,7 @@ export abstract class BaseScope<$ extends {} = {}> {

this._exportedResolutions = resolutionsOfModule(this, this._exports)

Object.assign(this.json, resolutionsToJson(this._exportedResolutions))
this._json = resolutionsToJson(this._exportedResolutions)
Object.assign(this.resolutions, this._exportedResolutions)

this.references = Object.values(this.referencesById)
Expand Down
83 changes: 83 additions & 0 deletions ark/type/__tests__/imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ contextualize(() => {

const exports = imported.export()

attest(Object.keys(exports)).equals(["a"])
attest(exports.a.expression).snap('"no" | "yes" | 3 | 60 | true')

attest<Module<{ a: 3 | 60 | "no" | "yes" | true }>>(exports)
})

Expand Down Expand Up @@ -83,6 +86,86 @@ contextualize(() => {
}
)

it("docs example", () => {
const shapeScope = scope({
// aliases with a "#" prefix are treated as private
"#baseShapeProps": {
perimeter: "number",
area: "number"
},
ellipse: {
// when referencing a private alias, the "#" should not be included
"...": "baseShapeProps",
radii: ["number", "number"]
},
rectangle: {
"...": "baseShapeProps",
width: "number",
height: "number"
}
})

// private aliases can be referenced from any scoped definition,
// even outside the original scope
const partialShape = shapeScope.type("Partial<baseShapeProps>")

attest<{
perimeter?: number
area?: number
}>(partialShape.t)
attest<typeof shapeScope>(partialShape.$)

attest(partialShape.expression).snap(
"{ area?: number, perimeter?: number }"
)

// when the scope is exported to a Module, they will not be included
// hover to see the Scope's exports
const shapeModule = shapeScope.export()

attest(Object.keys(shapeModule)).equals(["ellipse", "rectangle"])
attest(shapeModule).type.toString.snap(`Module<{
ellipse: {
perimeter: number
area: number
radii: [number, number]
}
rectangle: {
perimeter: number
area: number
width: number
height: number
}
}>`)
})

it("docs import example", () => {
const utilityScope = scope({
"withId<o extends object>": {
"...": "o",
id: "string"
}
})

const userModule = type.module({
// because we use `import()` here, we can reference our utilities
// internally, but they will not be included in `userModule`.
// if we used `export()` instead, `withId` could be accessed on `userModule`.
...utilityScope.import(),
payload: {
name: "string",
age: "number"
},
db: "withId<payload>"
})

attest(Object.keys(userModule)).equals(["payload", "db"])
attest(userModule).type.toString.snap(`Module<{
payload: { name: string; age: number }
db: { name: string; age: number; id: string }
}>`)
})

it("binds destructured exports", () => {
const types = scope({
foo: "1",
Expand Down
33 changes: 33 additions & 0 deletions ark/type/__tests__/objects/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,37 @@ contextualize(() => {
]
})
})

it("object keyword treated as empty", () => {
const t = type({
"...": "object",
foo: "string"
})

attest<{
foo: string
}>(t.t)
attest(t.expression).snap("{ foo: string }")
})

it("narrowed object keyword treated as empty", () => {
const t = type({
"...": type.object.narrow(() => true),
foo: "string"
})

attest<{
foo: string
}>(t.t)
attest(t.expression).snap("{ foo: string }")
})

it("errors on proto node", () => {
attest(() =>
type({
"...": "Date",
foo: "string"
})
).throws(writeInvalidSpreadTypeMessage("Date"))
})
})
Loading

0 comments on commit 0f5d566

Please sign in to comment.