Primate

Primate 0.33: Everything is typed, Grain backend and new website

Today we're announcing the availability of the Primate 0.33 preview release. This release features a full rewrite of Primate in and for TypeScript, Grain backend support, and a new website.

If you're new to Primate, we recommend reading the Quickstart page to get a quick idea.

Full TypeScript rewrite

Primate 0.33 marks a significant milestone: we've rewritten the framework in TypeScript from the ground up.

Typed routes

Routes now have full type inference for path parameters, query strings, and request bodies:

// routes/user/[id].ts
import route from "primate/route";
import pema from "pema";
import string from "pema/string";
import uint from "pema/uint";

const Query = pema({
  include: string.optional(),
});

route.get(request => {
  // TypeScript knows `id` is a string from the path parameter
  const id = request.path.get("id");

  // query parameters are fully typed
  const { include } = request.query.parse(Query);

  // return type is inferred and type-checked
  return {
    user: { id, name: "John" },
    included: include ? ["profile", "settings"] : []
  };
});

Typed sessions

Session data is now fully typed throughout your application:

// config/session.ts
import session from "primate/config/session";
import pema from "pema";
import string from "pema/string";
import date from "pema/date";

const SessionData = pema({
  userId: string,
  lastActivity: date,
});

export default session({
  schema: SessionData,
  cookie: { name: "app_session" }
});

Now in your routes, session access is type-safe:

import session from "#session";
import route from "primate/route";

route.get(() => {
  if (!session.exists) {
    session.create({
      userId: "user123",
      lastActivity: new Date()
    });
  }

  const data = session.get();
  // TypeScript knows: data.userId is string
  return `Welcome back, user ${data.userId}`;
});

Typed internationalization

I18N is now fully typed with key autocompletion and parameter validation.

Add a locale.

// locales/en-US.ts
import locale from "primate/i18n/locale";

export default locale({
  welcome: "Welcome to {appName}!",
  user_greeting: "Hello, {name}! You have {count:n|{count} message|{count} messages}",
  settings: "Settings",
  logout: "Log out"
});

Configure i18n.

// config/i18n.ts
import i18n from "primate/config/i18n";
import en from "#locale/en-US";

export default i18n({
  defaultLocale: "en-US",
  currency: "USD",
  locales: {
    "en-US": en,
  }
});

In your components, you get full type checking:

// components/Welcome.tsx
import t from "#i18n";

export default function Welcome({ name, messageCount }: {
  name: string;
  messageCount: number;
}) {
  return (
    <div>
      <h1>{t("welcome", { appName: "Primate" })}</h1>
      <p>{t("user_greeting", { name, count: messageCount })}</p>
      <button onClick={() => t.locale.set("de-DE")}>
        {t("settings")}
      </button>
    </div>
  );
}

Typed database stores

Database operations are now fully typed with schema inference:

// stores/User.ts
import store from "primate/store";
import primary from "pema/primary";
import string from "pema/string";
import uint from "pema/uint";
import date from "pema/date";

export default store({
  id: primary,
  name: string.max(100),
  email: string.email(),
  age: uint.range(13, 120),
  created: date.default(() => new Date()),
}).extend(User => ({
  type R = typeof User.R;

  findByEmail(email: R["email"]) {
    return User.find({ email });
  },

  updateProfile(id: R["id"], updates: {
    name?: R["name"];
    email?: R["email"];
  }) {
    return User.update({ id }, updates);
  }
}));

Using the store in routes provides type safety:

// routes/users.ts
import User from "#store/User";
import route from "primate/route";
import pema from "pema";
import string from "pema/string";
import uint from "pema/uint";

const CreateUser = pema({
  name: string.max(100),
  email: string.email(),
  age: uint.range(13, 120),
});

route.get(async () => {
  const users = await User.find({});
  // TypeScript knows the exact shape of each user
  return users.map(user => ({
    id: user.id,
    name: user.name,
    isAdult: user.age >= 18
  }));
});

route.post(async request => {
  const userData = request.body.fields(CreateUser);
  const user = await User.insert(userData);

  // all properties are typed and validated
  return {
    success: true,
    user: {
      id: user.id,
      name: user.name,
      created: user.created.toISOString()
    }
  };
});

Grain backend support

This version introduces support for the Grain programming language as a backend. Grain is a "strongly-typed functional programming language for the modern web".

Setup and configuration

Make sure you install Grain and that the grain executeable is in your PATH.

Install the Primate Grain package.

npm install @primate/grain

Load it in your config.

// config/app.ts
import config from "primate/config";
import grain from "@primate/grain";

export default config({
  modules: [grain()],
});

Writing routes in Grain

Grain routes follow similar patterns to other Primate backends.

// routes/hello.gr
module Hello

from "primate/request" include Request
from "primate/response" include Response
from "json" include Json

use Request.{ type Request }
use Response.{ type Response }

provide let get = (request: Request) =>
  JsonObject([("message", JsonString("Hello from Grain!"))])

provide let post = (request: Request) => {
  let body = Request.Body.json(request)
  Response.json(JsonObject([
    ("received", body),
    ("processed", JsonBoolean(true))
  ]))
}

Grain integration was added by jtenner. Thank you!

New website

We've redesigned our website and documentation to reflect Primate's evolution into a fully-typed, universal web framework.

What's next

Check out our issue tracker for upcoming 0.34 features.

Fin

If you like Primate, consider joining our Discord server.

Otherwise, have a blast with everything being typed!