This project contains a working, mostly fleshed-out template for a SvelteKit app. It performs
The example models a workout, like at the gym. A Workout contains an ordered collection of Sets. A Set is made up of an ordered collection of Activities. Activities can be Exercises or Rest and have a duration, measured in a number of seconds.
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. Centralization also simplifies integration testing the API and mocking for UI tests.
Enforcing business rules, such as validating user inputs or handling database constraint violations, are communicated as part of an API’s return types. An InvalidResult type return provides a standard way for 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. For example, 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.