Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ const normalize = (s: string) => s.trim().replace(/\s+/g, " ");
const hasText = (s: string) => s.length > 0;

const Note = Val.companion<Note>()
// create mints the id once. Derivations do not run it again.
// create generates the id once. Derivations do not run it again.
.implCreate((text: string) => ({ id: crypto.randomUUID(), text }))
// seal validates and normalizes every payload.
.implSeal((note, seal): Result<Note> => {
const text = normalize(note.text);
return hasText(text) ? { ok: seal({ ...note, text }) } : { err: "empty note" };
})
// fixed excludes the minted id from patch.
// fixed excludes the generated id from patch.
.fixed<"id">()
// impl collects behavior. It infers each member's first parameter as Note.
.impl({
Expand Down
31 changes: 16 additions & 15 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@

## Values

| | |
| ---------------------------------- | -------------------------------------------------------- |
| `equals(a, b)` | deeply compares two Vals of the same type |
| `Val.of<V>(value)` | applies the default seal with the type named |
| `Val.of.nocopy<V>(value)` | makes a payload the value without copying |
| `Val.unwrap(value)` | returns a mutable copy of the payload |
| `Val.sealer<V>()` | creates a callable default sealer |
| `Val.sealer<V>().impl(fns?)` | adds the type's members, and ends the chain |
| `Val.companion<V>()` | starts a companion without a callable sealer |
| `Val.companion<V>().impl(fns?)` | the same, on a companion |
| `.implTrait(Tr, fns?)` | implements a trait the type declares |
| `.implTrait<Tr>(fns)` | the same, for a trait that implements nothing of its own |
| `Val.companion<V>().implSeal(f)` | registers a custom seal |
| `Val.companion<V>().implCreate(f)` | registers a function that creates a payload |
| `Val.companion<V>().fixed<K>()` | excludes keys from `patch` |
| | |
| ----------------------------------------- | ------------------------------------------------------------ |
| `equals(a, b)` | deeply compares two Vals of the same type |
| `Val.of<V>(value)` | applies the default seal with the type named |
| `Val.of.nocopy<V>(value)` | makes a payload the value without copying |
| `Val.unwrap(value)` | returns a mutable copy of the payload |
| `Val.sealer<V>()` | creates a callable default sealer |
| `Val.sealer<V>().impl(fns?)` | adds the type's members, and ends the chain |
| `Val.companion<V>()` | starts a companion without a callable sealer |
| `Val.companion<V>().impl(fns?)` | the same, on a companion |
| `.implTrait(Tr, fns?)` | implements a trait the type declares |
| `.implTrait<Tr>(fns)` | the same, for a trait that implements nothing of its own |
| `Val.companion<V>().implSeal(f)` | registers a custom seal |
| `Val.companion<V>().implCreate(f)` | registers a function that creates a payload |
| `Val.companion<V>().implCreate.noseal(f)` | the same, bypassing the seal for a payload it always accepts |
| `Val.companion<V>().fixed<K>()` | excludes keys from `patch` |

## Companion members

Expand Down
2 changes: 1 addition & 1 deletion docs/src/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ It enforces the conventions through types and linting.

- **Built-in linter**: Catch convention violations through ESLint, Oxlint or the standalone command.

- **Lightweight**: Starts from <!-- valof-minimal-bundle-size -->852 B
- **Lightweight**: Starts from <!-- valof-minimal-bundle-size -->896 B
gzipped<!-- /valof-minimal-bundle-size -->, with no runtime dependencies.

> For background on individual features, see
Expand Down
130 changes: 108 additions & 22 deletions notes/design.md

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions src/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,8 @@ type Built<E extends AnyEnum, N extends keyof VariantsOf<E>, FE, FV> = [FV] exte
: Returned<FV>;

/** A constructor and the same constructor without the copy. See `Val`'s `create`. */
type Mints<A, R> = ((payload: A) => R) & {
/** Reuses the seal without copying its terminal payload. */
type Creates<A, R> = ((payload: A) => R) & {
/** The same, without copying the payload. */
nocopy: (payload: A) => R;
};

Expand Down Expand Up @@ -334,14 +334,14 @@ type VariantFrame<E extends AnyEnum, N extends keyof VariantsOf<E>, FE, FV, T> =
>;

type Framed<E extends AnyEnum, N extends keyof VariantsOf<E>, R, Sealed, T> = Omit<T, Wired> & {
/** Mints the payload with its tag and seals it, so it returns whatever the seal returns. */
create: Mints<SeedFor<E, N>, R>;
/** Creates the payload with its tag and seals it, so it returns whatever the seal returns. */
create: Creates<SeedFor<E, N>, R>;
/** Derives through the same seal the constructor uses. See {@link Patch}. */
patch: (value: VariantOf<E, N>, patch: Patch<SeedFor<E, N>>) => R;
} & (Sealed extends true
? {
/** The single gate a payload of this variant passes. `create` and `patch` go through it. */
seal: Mints<Tagged<E, N>, R>;
seal: Creates<Tagged<E, N>, R>;
}
: Record<never, never>);

Expand Down Expand Up @@ -699,12 +699,12 @@ const state = (
const byTag = { match: matchTag, key: tag };

const built = (name: string, custom: Seal | undefined, fns: Payload): object => {
const mint = (payload: Payload) => ({ ...payload, [tag]: name });
const tagged = (payload: Payload) => ({ ...payload, [tag]: name });
if (callable) {
// The default seal, plus the `patch` that derives through it. The tag is not in the patch's
// type and the value carries it already, so the merge leaves it alone.
const sealer = Val.sealer() as unknown as Sealer;
const frame = (payload: Payload) => sealer(mint(payload));
const frame = (payload: Payload) => sealer(tagged(payload));
define(frame, "patch", sealer.patch);
for (const key of Object.keys(fns)) define(frame, key, fns[key]);
return frame;
Expand All @@ -722,7 +722,7 @@ const state = (
impl: (fns: object) => object;
};
}
).implCreate(mint);
).implCreate(tagged);
return composed ? chain.implSeal(composed).impl(fns) : chain.impl(fns);
};

Expand Down
30 changes: 23 additions & 7 deletions src/lint/scan/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "../ast.ts";
import type { Enum } from "../../enum.ts";
import type { Trait } from "../../trait.ts";
import type { Val } from "../../val.ts";
import type { AnyVal, CompanionBuilder, Val } from "../../val.ts";
import { original, symbolRef, type Bindings, type Root, type SymbolRef } from "./bindings.ts";

/** `K`, which must be a key of every member of `T`. */
Expand Down Expand Up @@ -54,6 +54,24 @@ export type CompanionSite = Where & {
seals: boolean;
};

type Called = { name: string | undefined; property: Node; receiver: Node };

/** Reads `.implCreate.noseal(f)` as the `implCreate` step. */
export function stepOf(callee: Node): Called | undefined {
const receiver = child(callee, "object");
const property = child(callee, "property");
if (!receiver || !property) return undefined;
const name = keyName(property, callee["computed"] === true);
if (
name === ("noseal" satisfies keyof CompanionBuilder<AnyVal>["implCreate"]) &&
receiver.type === "MemberExpression"
) {
const outer = stepOf(receiver);
if (outer?.name === "implCreate") return outer;
}
return { name, property, receiver };
}

/** Which entry point a chain grows from, however the name was bound. */
export function fromRoot(node: Node, bound: Bindings): Root | undefined {
const path = rootPath(node);
Expand Down Expand Up @@ -178,17 +196,15 @@ function readChain(node: Node, bound: Bindings): Chain {
if (current.type !== "CallExpression") return undefined;
const callee = child(current, "callee");
if (!callee || callee.type !== "MemberExpression") return undefined;
const receiver = child(callee, "object");
if (!receiver) return undefined;
const step = stepOf(callee);
if (!step) return undefined;
const { name, receiver } = step;
if (receiver.type !== "CallExpression") {
of = fromRoot(callee, bound);
if (!of) return undefined;
const step = rootPath(callee)?.at(-1);
root = step === "companion" ? "companion" : "sealer";
root = rootPath(callee)?.at(-1) === "companion" ? "companion" : "sealer";
return child(current, "typeArguments");
}
const property = child(callee, "property");
const name = property && keyName(property, callee["computed"] === true);
const [argument] = children(current, "arguments");
if (name && argument) steps.add(name);
return walk(receiver);
Expand Down
9 changes: 4 additions & 5 deletions src/lint/scan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import {
companionSite,
fromRoot,
stepOf,
typeReference,
valOf,
type CompanionSite,
Expand Down Expand Up @@ -525,7 +526,7 @@ function implTrait(
while (current?.type === "CallExpression") {
const inner = child(current, "callee");
if (!inner || inner.type !== "MemberExpression") break;
const receiver = child(inner, "object");
const receiver = stepOf(inner)?.receiver;
if (!receiver || receiver.type !== "CallExpression") break;
current = receiver;
}
Expand Down Expand Up @@ -737,10 +738,8 @@ function detachedStep(
): DetachedStep | undefined {
const callee = child(node, "callee");
if (!callee || callee.type !== "MemberExpression") return undefined;
const property = child(callee, "property");
const step = property && keyName(property, callee["computed"] === true);
if (!step || !STEPS.has(step)) return undefined;
const receiver = child(callee, "object");
const { name: step, property, receiver } = stepOf(callee) ?? {};
if (!step || !property || !STEPS.has(step)) return undefined;
if (!receiver || receiver.type === "CallExpression") return undefined;
if (fromRoot(callee, bound)) return undefined;
const where = at(property["start"] as number);
Expand Down
85 changes: 66 additions & 19 deletions src/val.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,20 +425,39 @@ export type Synchronous<G extends (...args: never[]) => unknown, Checked> =
* What `create` must be: any arguments, a payload out. The result is not a value yet. The type's
* seal closes it, so `create` cannot bypass the seal.
*/
type Minter<V extends AnyVal> = (...args: never[]) => SeedOf<V>;
type CreateImpl<V extends AnyVal> = (...args: never[]) => SeedOf<V>;

/** Marks a `create` registered by `.implCreate.noseal`. */
type Unsealed = { readonly __valof_noseal: true };

/**
* No arguments, so no input reaches the payload unsealed. A wrong payload is checked against
* `() => SeedOf<V>`, so the error points at the returned value. `G` stays bare, because an
* intersection stops TypeScript inferring it.
*/
type Argumentless<V extends AnyVal, G extends AnyFn> = [Parameters<G>] extends [[]]
? [ReturnType<G>] extends [SeedOf<V>]
? G
: () => SeedOf<V>
: Invalid<"`.implCreate.noseal` takes a function with no arguments">;

/** The public face of `create`: its own arguments, and whatever the seal returns. */
type Minting<V extends AnyVal, N, F> = N extends (...args: infer A) => unknown
? (...args: A) => Constructed<V, F>
: undefined;
type Creating<V extends AnyVal, N, F> = [N] extends [Unsealed]
? () => V
: N extends (...args: infer A) => unknown
? (...args: A) => Constructed<V, F>
: undefined;

type CreateMethod<V extends AnyVal, N, F> = [Minting<V, N, F>] extends [undefined]
type CreateMethod<V extends AnyVal, N, F> = [Creating<V, N, F>] extends [undefined]
? Record<never, never>
: {
/** Mints a payload and seals it, so it returns whatever the seal returns. */
create: Minting<V, N, F> & {
/** Reuses the minter and seal without copying their terminal payload. */
nocopy: Minting<V, N, F>;
/**
* Creates a payload and seals it, so it returns whatever the seal returns. With
* `.implCreate.noseal`, returns the Val.
*/
create: Creating<V, N, F> & {
/** The same, without copying the payload. */
nocopy: Creating<V, N, F>;
};
};

Expand Down Expand Up @@ -622,8 +641,8 @@ export type Sealer<V extends AnyVal, T extends CompanionMembers<V> = Record<neve
*
* Inferred, not written: it is exported so your own declarations can name it.
*
* `implSeal` and `implCreate` are separate steps because a seal must be idempotent. Minting
* belongs in `create`, whose payload is then sealed like any other.
* `implSeal` and `implCreate` are separate steps because a seal must be idempotent. Generating
* ids, timestamps and the like belongs in `create`, whose payload is then sealed like any other.
*/
export type CompanionBuilder<
V extends AnyVal,
Expand Down Expand Up @@ -661,8 +680,15 @@ export type CompanionBuilder<
...impl: Passes<Tr, G, V>
): CompanionBuilder<V, N, F, P, T & Unbound<MembersOf<Tr>, V>>;
};
/** Registers the payload-minting constructor as `create`. Any arguments, a payload out. */
implCreate: <G extends Minter<V>>(create: G) => CompanionBuilder<V, G, F, P, T>;
/** Registers a function that creates a payload, as `create`. Any arguments, a payload out. */
implCreate: (<G extends CreateImpl<V>>(create: G) => CompanionBuilder<V, G, F, P, T>) & {
/**
* A `create` that bypasses the seal and returns the Val, for a payload the seal always
* accepts, such as a random id. Takes no arguments. Development still runs the seal, and
* throws if it rejects the payload or if normalization changes it.
*/
noseal: <G extends AnyFn>(create: Argumentless<V, G>) => CompanionBuilder<V, Unsealed, F, P, T>;
};
/**
* Replaces the seal. Its parameter may be wider than the payload, so a schema library can parse
* into it, but not so wide that a wire format fits: see {@link CheckedSeal}.
Expand All @@ -671,7 +697,7 @@ export type CompanionBuilder<
/**
* Takes keys out of the derivation path: `patch` stops accepting them in its patch.
*
* For what a `create` mints and nothing afterwards may change: an id, a `createdAt`, a
* For what a `create` generates and nothing afterwards may change: an id, a `createdAt`, a
* version counter.
*
* ```ts
Expand Down Expand Up @@ -838,6 +864,10 @@ const changedDuringSeal = (): never => {
throw new TypeError("The payload changed while the custom seal was running.");
};

const notFixedPoint = (): never => {
throw new TypeError("The seal rejected or changed a payload from `.implCreate.noseal`.");
};

/**
* Builds the recursive deep copy, in an owning and a non-owning form.
*
Expand Down Expand Up @@ -974,6 +1004,7 @@ export const define = <T extends object>(target: T, key: string, value: unknown)
/** What the builder's steps registered. `.fixed` is a type argument alone, so it leaves none. */
type Ctors = {
create?: AnyFn;
noseal?: boolean;
seal?: (value: unknown, seal: (value: unknown) => unknown) => unknown;
};

Expand Down Expand Up @@ -1031,7 +1062,7 @@ const attach = (
traits: Record<string, unknown>,
callable: boolean,
): Record<string, unknown> => {
const { create, seal: custom } = ctors;
const { create, noseal, seal: custom } = ctors;
const sealedWith = (terminal: (value: unknown) => unknown): ((value: unknown) => unknown) =>
custom
? development
Expand All @@ -1056,15 +1087,29 @@ const attach = (
: terminal;
const seal = sealedWith(own);
const nocopy = sealedWith(adopt);
// Development runs the seal anyway, and throws unless it calls the default seal with an
// equal payload.
const unsealed = (terminal: (value: unknown) => unknown): ((value: unknown) => unknown) =>
custom && development
? (value) => {
const result: { sealed?: unknown } = {};
sealedWith((candidate) => {
if (!equalsUnknown(value, candidate)) notFixedPoint();
return (result.sealed = terminal(candidate));
})(value);
return "sealed" in result ? result.sealed : notFixedPoint();
}
: terminal;
// A derivation that changed nothing returns the value it started from, so a framework comparing
// by identity sees no update. A custom seal owns the return shape, so the value goes back
// through it: the copy inside recognizes the node and returns the same one.
const keep: (value: unknown) => unknown = custom ? seal : (value) => value;

if (create) {
const minted = (...args: never[]) => seal(create(...args));
minted.nocopy = (...args: never[]) => nocopy(create(...args));
target.create = minted;
const [copying, adopting] = noseal ? [unsealed(own), unsealed(adopt)] : [seal, nocopy];
const created = (...args: never[]) => copying(create(...args));
created.nocopy = (...args: never[]) => adopting(create(...args));
target.create = created;
}
if (custom) {
define(seal, "nocopy", nocopy);
Expand Down Expand Up @@ -1134,7 +1179,9 @@ const build = <V extends AnyVal>(
});
if (callable) return target;

target.implCreate = (create: AnyFn) => step({ ...ctors, create });
target.implCreate = Object.assign((create: AnyFn) => step({ ...ctors, create, noseal: false }), {
noseal: (create: AnyFn) => step({ ...ctors, create, noseal: true }),
});
target.implSeal = (seal: NonNullable<Ctors["seal"]>) => step({ ...ctors, seal });
target.fixed = () => step(ctors);
return target;
Expand Down
6 changes: 3 additions & 3 deletions tests/enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ describe("the declaration", () => {
const Crate = Enum.companion<Crate>()
.implSeal((c, seal) => seal(c))
.impl();
const [minted, checked, copied] = [["a"], ["a"], ["a"]];
Crate.Full.create.nocopy({ items: minted });
const [created, checked, copied] = [["a"], ["a"], ["a"]];
Crate.Full.create.nocopy({ items: created });
Crate.Full.seal.nocopy({ items: checked, _tag: "Full" });
Crate.Full.create({ items: copied });
expect([minted, checked, copied].map(Object.isFrozen)).toEqual([true, true, false]);
expect([created, checked, copied].map(Object.isFrozen)).toEqual([true, true, false]);
});

test("the payload is deep-copied, like any other Val", () => {
Expand Down
9 changes: 9 additions & 0 deletions tests/lint/rules/bypassed-companion/fixtures/noseal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Val } from "valof";

type Age = Val<"Age", number>;

const Age = Val.companion<Age>()
.implCreate.noseal(() => 0)
.implSeal((n, seal) => seal(Math.trunc(n)));

export const zero = (): Age => Val.of<Age>(0);
6 changes: 6 additions & 0 deletions tests/lint/rules/bypassed-companion/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ test("names the seal that the lift skips", async () => {
]);
});

test("reads the chain through `.implCreate.noseal`", async () => {
expect(await messages("noseal")).toEqual([
"Val.of<Age> bypasses Age.seal, which checks the payload",
]);
});

test("says what is missing where the companion has no seal to name", async () => {
expect(await lint("no-seal")).toEqual([
{
Expand Down
Loading
Loading