diff --git a/ark/docs/content/docs/scopes/index.mdx b/ark/docs/content/docs/scopes/index.mdx index 398f5d1d6..e43f004a6 100644 --- a/ark/docs/content/docs/scopes/index.mdx +++ b/ark/docs/content/docs/scopes/index.mdx @@ -199,7 +199,62 @@ Luckily, despite its appearance, the type otherwise behaves as you'd expect- Typ ### 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") + +// 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", + 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" +}) +``` ### submodules diff --git a/ark/type/__tests__/imports.test.ts b/ark/type/__tests__/imports.test.ts index 5692cc5bc..95d45d63f 100644 --- a/ark/type/__tests__/imports.test.ts +++ b/ark/type/__tests__/imports.test.ts @@ -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>(exports) }) @@ -83,6 +86,71 @@ 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") + + attest<{ + perimeter?: number + area?: number + }>(partialShape.t) + attest(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() + }) + + it("docs import example", () => { + const utilityScope = scope({ + "withId": { + "...": "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" + }) + + attest(Object.keys(userModule)).equals(["payload", "db"]) + attest(userModule).type.toString.snap() + }) + it("binds destructured exports", () => { const types = scope({ foo: "1", diff --git a/ark/type/__tests__/objects/merge.test.ts b/ark/type/__tests__/objects/merge.test.ts index bfdf1bcbd..906536a42 100644 --- a/ark/type/__tests__/objects/merge.test.ts +++ b/ark/type/__tests__/objects/merge.test.ts @@ -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() + }) + + 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")) + }) }) diff --git a/ark/type/parser/objectLiteral.ts b/ark/type/parser/objectLiteral.ts index fd82f59fa..d23ed7ec6 100644 --- a/ark/type/parser/objectLiteral.ts +++ b/ark/type/parser/objectLiteral.ts @@ -1,4 +1,5 @@ import { + intrinsic, normalizeIndex, type BaseParseContext, type BaseRoot, @@ -57,7 +58,13 @@ export const parseObjectLiteral = ( if (!isEmptyObject(structure)) return throwParseError(nonLeadingSpreadError) const operand = ctx.$.parseOwnDefinitionFormat(v, ctx) - if (!operand.hasKind("intersection") || !operand.structure) { + // treat object domain as empty for spreading (useful for generic constraints) + if (operand.equals(intrinsic.object)) continue + if ( + !operand.hasKind("intersection") || + // still error on attempts to spread proto nodes like ...Date + !operand.basis?.equals(intrinsic.object) + ) { return throwParseError( writeInvalidSpreadTypeMessage(operand.expression) )