This project contains a working, mostly fleshed-out template for a SvelteKit app. It performs
The entities are modeled as TypeScript types in $lib/entities.d.ts
. In addition to its strongly typed definition, each entity has a Pending
version. Pending entities are loosely typed, allowing them to be bound to inputs, for example, in a HTML form that only supports string
inputs (well, FormData
).
---
# title: Workout Builder
# config:
# layout: elk
# look: handDrawn
---
erDiagram
Workout {
string label
string name
string description
}
Set {
string label
string name
}
Activity {
number duration
string[] instructions
}
Exercise {
string label
string name
string description
}
Rest { }
Workout ONE TO ONE OR MORE Set : "has"
Set ONE TO ONE OR MORE Activity : "has"
Activity ||..|| Exercise : "is"
Activity ||..|| Rest : "is"
Exercise ONE TO ZERO OR MORE Exercise : "alternatives"
Each entity has the following default routes:
/entities
: Lists all instances, allows clicking into/[label]
: An individual entity instance, by default, read-only/edit
: A form view that allows for updating an individual instance/new/
: A form view to create a new instance. A POST
redirects to /eneties/[label]
or /eneties/[label]/edit
.All data access goes through an API library, $lib/server/api.js
. The API is responsible for encapsulating the database and enforcing business rules. The $lib/server
path ensures that it‘s not executed on the client.
Enforcing business rules, such as validating user inputs or handling database constraint violations, are communicated as part of an API’s return types. And InvalidResult
type return allows a function to return the user input and a collection of one or more validation errors. APIs should only throw (or bubble) exceptions for unexpected states that the user cannot fix themselves by submitting different data. An empty value for a required property is a validation error, not an exceptional case. The user should resubmit with a different value. A dropped database connection, on the other hand, is an error state that the user can’t do anything about.
$lib/server/api.js
/** * (1) * @param {PendingEntity} input * (2) * @returns {Promise<Result<PendingEntity, Entity, 'entity'>>} */ export async function create_entity(input) { /** @type {Validation<Entity>[]} */ const validations = []; // (3) if (!test(input.property)) validations.push({ message: 'Name is required', for: 'property' }); // (4) if (has(validations)) { return { entity: input, validations }; } // (5) const entity = await /** @type {Entity} */ ( db.query(`INSERT entity RETURNING …`); ); return entity; }
Pending
version of the entity type. This allows (mostly) straightforward mapping from FormData
in the calling form handler.Result<In, Out, Prop>
return type to convey return values.In
is the type of input
, for CRUD operations, usually the Pending
version of an entityOut
is the expected return type, often the strongly typed form of the pending input. Postel’s Law: “Be conservative in what you send, be liberal in what you accept from others.”Prop
is the string
name of the property in which the input
will be stashed in the InvalidResult
instance.+page.server.js
, should be “dumb”. They should be responsible for collecting data from the UI and passing to the appropriate API. APIs implement the valaidation logic. Validation errors return InvalidResult
instances. Page handlers can use the is_valid()
guard function to differentiate between a Result
and InvalidResult
type.Entity
return type. Thus APIs that don’t do any validation do not need to do the Result
/InvalidResult
rigamarole.+page.server.js
, form actions/** @satisfies {import('./$types').Actions} */ export const actions = { create: async ({ request }) => { // (1) const entity_input = /** @type {PendingEntity} */ ( Object.fromEntries(await request.formData()) ); // (2) const entity = await api.create_entity(entity_input); // (3) if (is_invalid(entity)) return fail(400, entity); //return { entity }; return redirect(303, `/entitys/${entity.label}`); } };
Pending
entity from the submitted FormData
. More complex objects or form abstractions might need specific mapping logic. The type assertion is a little heavy handed. However, FormData
is difficult to correctly tpye.Pending
entity into its stongly typed instance.fail()
response to convey an HTTP 400
error that’s available to the page in the form
property of $props()
. Validation errors get passed through the fail()
Response
and are available as form?.validations
in the front-end. Exceptions return a 500
error()
. Generally errors should be allowed to bubble and handled at the closest parent error boundary.