skii

Toolkit for building typesafe APIs

Usage no npm install needed!

<script type="module">
  import skii from 'https://cdn.skypack.dev/skii';
</script>

README

Skii

Table of contents

Motivation

Skii is a toolkit for creating typesafe backends.

The fundamental complexity of backend development is transferring and transforming data as it passes between your database, your ORM, your API endpoints, your caching layer, and the client. It's your job to write code to ensure that the data is in the proper shape at each step along the way.

Usually, you end up with a codebase that's a rat's nest of duct tape and type validators. These type validators may be explicitly defined (using tools like Joi, Yup, or Zod), implicitly defined (the return type of SQL query), or composed entirely of hopes and prayers (if you don't use TypeScript!).

That's where Skii comes in. Skii makes it easy to define and enforce the shape of your data as it passes through the layers of your application.

But we'll get to that. Let's start with some basics.

Installation

yarn add skii
npm install --save skii

Basic usage

Primitives

const stringSchema = skii.string();
const numberSchema = skii.number();
const booleanSchema = skii.boolean();
const bigintSchema = skii.bigint();
const dateSchema = skii.date();

The core of Skii is a validation library, similar to Yup, Joi, or io-ts. If you've never heard of any of these, don't worry, no prior experience is required.

Let's create a simple schema that validates strings.

import skii from 'skii';

const stringSchema = skii.string();

Every Skii schema has certain methods.

Parsing

We can use the .parse method to check a value against the schema type.

stringSchema.parse('asdf'); // => return "asdf"
stringSchema.parse(12); // => TypeError

Type inference

You can infer the TypeScript type of any schema like so:

const A = z.string();
type A = z.infer<typeof A>; // string

Optional

We can make schemas optional like so:

const stringOrUndefined = stringSchema.optional(); // returns a
stringOrUndefined.parse(undefined); // => passes, returns undefined

type t = z.infer<typeof stringOrUndefined>;
// string | undefined

Nullable

Similarly, we can make a schema nullable like so:

const stringOrNull = stringSchema.nullable();
stringOrUndefined.parse(null); // => passes, returns null

type t = z.infer<typeof stringOrUndefined>;
// string | null

Immutability

Skii schemas are immutable! All methods return a new schema instance.

.parse, .optional, and .nullable are available on any Skii schema.

Enums

const breed = skii.string().oneOf('lab', 'schnauzer', 'beagle');
// SkiiType<"lab" | "schnauzer" | "beagle">

Arrays

Objects

  • merge
  • augment
  • pick
  • omit
  • partial

Example

1. Define your data models in Skii

import skii from 'skii';

const User = skii.model({
  name: 'User',
  fields: {
    id: skii.id(),
    firstName: skii.field(skii.string(), {
      label: 'First name',
    }),
  },
});

2. Spec your API

const app = skii.rest({
  contextType: skii.object({
    token: skii.string(),
  }),
  context(req, res) {
    // if the return type doesn't match contextType
    // a 400 error is sent
    return { token: skii.string().parse(req.headers['Authorization']) };
  },
});

// familiar Express-like API
app
  .patch((ctx) => {
    return {
      path: skii.path().push('user').param('id',skii.string()),
      body: schema.models.User.partial(), // validator for request body
      returns: schema.models.User.fields() // strip all relations
      authorize: (ctx) => {
        const token = ctx.token;
        // run authorization check
        return true;
      },
    };
  })
  .implement(({ query, body, req, res, next, ctx }) => {
    req.body; // any
    req.query; // any
    query; // { id: string }
    body; // => { firstName: string }

    const updatedUser = ctx.orm.updateUser(body) // update user here
    return updatedUser;
  });

app.serve(3000);

This is an example for implementing a REST API but Skii has built-in modules to define REST, RPC, and GraphQL APIs in a totally typesafe way.

3. Generate a TypeScript client SDK from your API

// ./server/app.ts
app.export.toSDK({
  path: `${__dirname}/../client/sdk.ts`,
});

// ./client/index.ts
import SDK from './sdk';

const setFirstName = async (firstName: string) => {
  const updatedUser = await SDK.user.id('ID_HERE').patch({ firstName });
  updatedUser; // { id: string, firstName: string };
};

Installation

yarn add skii

npm install skii --save

Concepts

Schema definitions vs API definitions

Moving forward, it is vital that you understand the difference between "model definitions" and "API definitions". GraphQL users tend to be acutely aware of this distinction, but if you have a background implementing traditional SQL-backed REST APIs it may be confusing initially.

Model definitions define the data types that will be stored in your database.

API definitions define the data types that are accepted or returned by the API consumed by the client.

If you've ever created an ORM model, that would be classified as a "schema definition" since ORMs provide helper functions for writing to your database. On the other hand, GraphQL definitions are "API definitions" since they define what data your clients can read or write to the database.

In Skii you define your models first. Then you create API endpoints using your models as a basis. What this means exactly will become clear as you read through these docs.

Usage

Import

import Skii from 'skii';

Create a Skii instance

You create an "instance" of skii with the SkiiFactory method.

import Skii from 'skii';

export const s = Skii.create();

Create model definitions

You can use the Skii instance to define data util.

const Player = s.model({
  define() {
    return {
      id: s.id(),
      email: s.string().required(),
      points: s.int(),
    };
  },
});

You can use the following methods to define the properties of a model

// for primary keys
s.id();

// primitive types
s.string();
s.int();
s.float();
s.boolean();

// arrays of primitive types
s.array.string();
s.array.float();
s.array.int();
s.array.boolean();

With the exception of s.id() (which is always considered required), you can append .required() to any of these to make it a required field.

With the exception of s.id() (which is always considered unique), you can append .unique() to any property to enforce uniqueness.

Relations

Use s.toOne and s.toMany to create relations between models.

const Guild = s.model({
  define() {
    return {
      id: s.id(),
      name: s.string().required(),
      createdBy: s.toOne(Player),
      players: s.toMany(Player),
    };
  },
});

Recursive relations

To create recursive relations without getting TypeScript errors, you have to explicitly type your models.

interface Player {
  id: string;
  email: string;
  points?: number;
}

const Player: s.model<Player> = s.model({
  define() {
    return {
      id: s.id(),
      email: s.string().required(),
      points: s.int(),
      guild: s.toOne(Guild), // adding this creates a cycle
    };
  },
});

interface Guild {
  id: string;
  name: string;
  createdBy: Player;
  members: Player[];
}

const Guild: s.model<Guild> = s.model({
  define() {
    return {
      id: s.id(),
      name: s.string().required(),
      createdBy: s.toOne(Player),
      players: s.toMany(Player),
    };
  },
});

Validation checks

Currently Skii doesn't support validation checks beyond verifying basic types (string, float, etc). We plan to incorporate more advanced validation (e.g. number min/max, string length, regex support, isEmail, isURL, etc) in a future release.

Configuration

import Skii from "skii";


const s = Skii.create<{}>

For all but the simplest cases, you'll want to configure your Skii instance by passing a type the

You are able to configure various options by passing a TypeScript type into the generic SkiiFactory function, like so:

const s = SkiiFactory<{
  context: { userId: string; token: string };
}>();

Define schema definitions

You define your data types with skii.model.

const User = skii.model({
  define(){
    return {
      firstName: s.string().required().authorize(())
    }
  }
})