Skip to main content

Welcome

In your grand search to find the perfect typescript web framework, we are humbled that you have stumbled upon us. We are new to the game, having just officially released in April of 2025, but we believe our offering to you was worth the wait.

What is Dream?

At the heart of most web applications is a database, with, at least generally speaking, a tightly-defined set of table schemas to guard the integrity of its data. Dream follows the conventional Active Record practices for modeling data, but provides a very powerful TypeScript-driven set of features to unleash powerful autocomplete mechanisms that make even the most dense applications possible to navigate.

const deco = new Decorators<typeof User>()

@deco.SoftDelete()
export default class User extends ApplicationModel {
public get table() {
return 'users' as const
}

public id: DreamColumn<User, 'id'>
public name: DreamColumn<User, 'name'>
public createdAt: DreamColumn<User, 'createdAt'>
public updatedAt: DreamColumn<User, 'updatedAt'>
public deletedAt: DreamColumn<User, 'deletedAt'>

@deco.Validates('contains', '@')
@deco.Validates('presence')
public email: string

@deco.Validates('length', { min: 4, max: 18 })
public passwordDigest: string

@deco.Virtual()
public password?: string | null
public passwordDigest: DreamColumn<User, 'passwordDigest'>

@deco.BeforeSave()
public async hashPass() {
if (this.password) this.passworDigest = await argon2.hash(this.password)
this.password = undefined
}

public async checkPassword(password: string) {
if (!this.passworDigest) return false
return await argon2.verify(this.passworDigest, password)
}
}

Powerful associations

In addition to powerful decorators for describing validations and custom scoping on your models, Dream also provides a powerful association layer, enabling you to describe rich, intimate associations with elegant simplicity:

const deco = new Decorators<typeof Post>()

class Post extends ApplicationModel {
...

@deco.BelongsTo('User')
public user: User

@deco.HasMany('Comment')
public comments: Comment[]

@deco.HasMany('Reply', { through: 'comments' })
public replies: Reply[]
}

const post = await Post.firstOrFail()
await post
.innerJoin('comments as c', { and: { body: ops.ilike('%oops%') } })
.where({ 'c.authorName': 'brittany' })
.all()
// [Post{ body: 'oops' }, Post{ body: 'oops, I did it again' }]

See the Dream guides for more information on modeling with Dream

Serialization

Dream provides first-class serialization support, empowering devs to easily bind serializers to models, and then exploit those connections throughout your application.

class User extends ApplicationModel {
public get serializers(): DreamSerializers<User> {
return {
default: 'UserSerializer',
summary: 'UserSummarySerializer',
}
}
}

// somewhere, maybe in a controller...
await User.preloadFor('summary').findOrFail(this.castParam('id', 'uuid'))

To learn more about automatic preloading, see our preloadFor documentation.

What is Psychic?

Psychic is a fully-featured, TypeScript-driven web framework built on Express. It leverages the MVC (Model, View, Controller) paradigm, providing the Dream ORM under the hood for data modeling. Philosophically, Psychic and Dream use opinionated conventions to enable seamless development, allowing beautiful type integrations to flow throughout your app with ease. By following a strict convention-over-configuration philosophy throughout our design, we enable you to write less while following best practice design concepts.

Routing

Psychic enables you to programatically define routes, as well as their connections to controllers within your app. With these routes defined, your web server will automatically point any requests to these paths to the matching controllers.

// conf/routes.ts

import { PsychicRouter } from '@rvoh/psychic'

export default (r: PsychicRouter) => {
r.get('', 'Welcome#index')
r.namespace('v1', r => {
r.namespace('host', r => {
r.resources('places')
})
})
}

See our Routing guide for more information on routing

Controllers

With routes defined, you can build matching controllers to add functionality to your endpoints. Psychic deals strictly in json, so all endpoints will generally just be rendering json, if anything.

// controllers/V1/Host/PlacesController.ts

export default class V1HostPlacesController extends AuthedController {
public async create() {
const place = await Place.create(this.paramsFor(Place))
this.created(place.id)
}

public async index() {
const places = await Place.preloadFor('summary').all()
this.ok(places)
}

public async show() {
const place = await Place.preloadFor('default').findOrFail(this.castParam('id', 'bigint'))

this.ok(place)
}

public async update() {
const place = await Place.findOrFail(this.castParam('id', 'bigint'))
await place.update(this.paramsFor(Place))
this.noContent()
}

public async destroy() {
const place = await Place.findOrFail(this.castParam('id', 'bigint'))
await place.destroy()
this.noContent()
}
}

As you can already see above, our Dream ORM is clearly at work to keep our code so tidy. Psychic will automatically respond with a 404 for any failures caused by findOrFail, and castParam will fail with a 400 if the incoming param does not match the described schema. These design patterns enable you to get out of your own way, enabling the composition of extremely simple design patterns with powerful intuitions about your needs.

See our Controller guides for more information on implementing controllers

Openapi

Psychic provides a full-throttle openapi engine that can autogenerate openapi documents for your app by examining your models and serializers. This is very powerful, since it prevents you from needing to manually update your openapi specs any time you make changes to your models or serializers. It can also infer request body shapes for models on POST, PATCH, and PUT requests, enabling automatic request body generation as well.

// controllers/V1/Host/PlacesController.ts

export default class V1HostPlacesController extends AuthedController {
// passing Place will automatically generate an openapi request body
// containing all of the "safeParams" fields from the model.
@OpenAPI(Place, {
status: 201,
responses: {
201: {
type: 'string',
},
},
})
public async create() {
const place = await Place.create(this.paramsFor(Place))
this.created(place.id)
}

// passing many: true will specify an openapi document that renders
// an array of ingredients. using the "summary" serializerKey will
// tell openapi to use the "summary" serializer, which should be
// defined somewhere in the inheritance chain for the Ingredient
// model, if not directly on the Ingredient model, as one of the
// `serializers` getter return value properties.
@OpenAPI(Ingredient, {
status: 200,
paginate: true,
serializerKey: 'summary',
})
public async index() {
const paginated = await Ingredient.preloadFor('summary').paginate()
this.ok(paginated)
}
}

The create method will automatically generate an openapi document for this endpoint which responds with a 201 status and a string, but has a request body that matches all of the safeParams getter on user. This will default to all of the attributes on the Place model, except for any belongs to association foreign keys, the primary key, or the timestamp fields.

Utilizing the preloadFor method, we can load all nested association chains required to serve up the model for this particular serializer.

The index method will automatically generate an openapi document that responds with a 200, and renders an array of Places using the PlaceSummarySerializer, paginated automatically by dream using the paginate method. Passing the paginate: true flag, we indicate that the response will also contain additional aside from the data payload to help support pagination flows for a client. You can do quite a lot with the OpenAPI decorator, so it is worth reading up on the docs there to unlock the full potential of our powerful openapi integration.

See our Openapi guides for more information on implementing controllers

Philosophy

Psychic and Dream provide an end-to-end solution for modern web applications, without getting in the way by intervening in the front end client building process. Instead, we encourage Psychic developers to use whatever front end framework they want for prototyping their app, and we make little to no interventions, setting it up whatever way they see fit. We do not provide any templating engines, or any mechanisms for rendering anything other than json data, which can be consumed by whatver API consumers need to do so.

We do, however, provide a tools for composing a robust backend, as well as the testing infrastructure to cover any set of web client integrations you desire.

Keeping DRY

Don't Repeat Yourself (DRY), is a guiding philosophy of Dream and Psychic, revealing itself in:

  • model attribute types that are derived from the database, so when you write a migration that, for example, adds an enum, the one place that you define the change is in the migration, and it automatically cascades everywhere the enum is referenced or set
  • OpenAPI definitions that are fleshed out automatically from the routes file and model serializers
  • HasOne, HasMany, and BelongsTo association decorators that encapsulate all the complexity of an association into a single declaration that, once defined, becomes an abstraction in a well defined domain
  • powerful decorators like @SoftDelete, @Sortable, and @ReplicaSafe that automatically and universally handle common use cases which would otherwise introduce complexity into your application
  • cli code generators that set up models, serializers, and controllers using best practice conventions, such as controllers inheriting from an authenticated ancestor right from the start
  • advanced association patterns such as has-many-through, single table inheritance (STI), and polymorphism
  • utilities like paramsFor and preloadFor, which help you stay DRY when defining new associations on your serializers, or add or edit columns on your table.

Use familiar technologies

In choosing to provide a framework, we were not interested in reinventing the wheel at every turn, which is why, like most in the nodejs world, have turned to leaning on popular open source tooling to provide the underlying mechanisms of our framework. This enables us to focus on providing the important features we care about, while leaning on tools everyone is familiar with for driving the rest.

Considering, here is a breakdown of the technologies we are leaning on for our application stack:

  • kysely (used for driving the Dream ORM)
  • expressjs (drives the underlying web server)
  • openapi (drives integration with front end clients)
  • node-pg (used for driving the ORM)
  • ioredis (adds node bindings to redis, enabling us to support both distributed websocket systems, as well as background job systems)
  • bullmq (used for driving background jobs)
  • socket.io (used for websockets)
  • vitest (used for driving unit and feature tests)
  • puppeteer (used for running a headless browser during your feature tests)

Convention over configuration

Since Psychic and Dream are meant to be used together, Psychic is well-fit to automatically absorb implicit configurations at the Dream layer, enabling you to define things once, rather than many times. These sensible expectations by our app enable you to compose with ease, and make changes that can flow through to the top level of your app without even needing to make changes.

Leverage openapi to simplify front end integration

In the modern front end javascript ecosystem, there are simply too many options for developers for us to have any desire to try taking on direct integrations with any client framework directly. By not getting in the way, we believe we provide the most flexibility, integration-wise. However, we recognize there are a few points at which front end integrations become useful, and provide the binding mechanisms to make that possible.

Auto-launching the client when a Psychic dev server starts

If you opt into a client when provisioning your Psychic app, you will automatically have a few lines of code added to your conf/app.ts file, which will automatically launch your client server whenever your app launches with NODE_ENV=development.

// conf/app.ts

export default async (psy: PsychicApp) => {
...
psy.on('server:start', async psychicServer => {
if (AppEnv.isDevelopment && AppEnv.boolean('CLIENT')) {
await PsychicDevtools.launchDevServer('client', { port: 3000, cmd: 'yarn client' })
}
})

psy.on('server:shutdown', () => {
if (AppEnv.isDevelopment && AppEnv.boolean('CLIENT')) {
PsychicDevtools.stopDevServer('client')
}
})
}

The PsychicDevtools.launchDevServer command is extremely flexible. Providing it with a command to run and a port to expect to be occupied once the command launches will enable psychic to side-launch your client server. If you have multiple servers you need to launch, there is nothing to prevent you from adding it. Just don't forget that any server launched in the server:start hook will need a corresponding stopDevServer call in server:shutdown.

In addition, because of the flexibility provided, you are able to side-launch commands that are not in any way associated with your code repository, enabling you to achieve client integration without even enabling a client within Psychic, providing ultimate flexibility for devs.

Testing

Psychic can easily act as a standalone json web delivery system, but it also encourages certain paradigms which enable the developer to still write end-to-end tests, as well as a rich tooling system for composing unit tests. Psychic was designed with a BDD philosophy in mind, which means that our system must provide adequate tooling for spec'ing out our entire app.

As most in the javascript world are comfortable with vitest, we have built our tooling to rest comfortably on top of it, facilitating the integration of custom vitest or jest plugins of your choice without any trouble from the framework. We do, however, provide some useful extensions to make your life easier when spec'ing in Dream and Psychic.

Unit specs

Unit specs describe the behavior of your app. When practicing BDD, the unit specs are written before the functionality they describe, and enable you to test the behavior of your function from the outside in. Psychic and Dream provide special tools to enhance this process and make it seamless for you to interact with your app in a test environment.

For more information, see The Unit spec guides.

Dream (model) specs

When spec'ing your models, you can leverage special vitest extensions to make assertions simple, and can very easily test all sorts of extraneous behavior using the same suite of tools:

describe('User', () => {
describe('upon creation', () => {
context('UserSettings model creation', () => {
it('creates a user settings model, and attaches it to the user', async () => {
expect(await UserSettings.count()).toEqual(0)
const user = await createUser()
expect(await UserSettings.firstOrFail()).toMatchDreamModel(user.userSettings)
})
})
})

describe('#checkPassword', () => {
let user: User

beforeEach(async () => {
user = await createUser({ password: 'howyadoin' })
})

context('with a valid password', () => {
it('returns true', async () => {
expect(await user.checkPassword('howyadoin')).toBe(true)
})
})

context('with an invalid password', () => {
it('returns false', async () => {
expect(await user.checkPassword('invalid')).toBe(false)
})
})
})
})

Controller specs

Psychic also provides helpers to enable easy endpoint testing, making it simple to hit your routes with real requests and test the response mechanisms of your app under a variety of circumstances.

// api/spec/unit/controllers/Api/V1/UsersController.spec.ts

import { PsychicServer } from '@rvoh/psychic'
import { OpenapiSpecRequest } from '@rvoh/psychic-spec-helpers'
import createUser from '../../../../factories/UserFactory'
import { validationOpenapiPaths } from '../../../../../src/types/openapi/validation.openapi.js'

const request = new OpenapiSpecRequest<validationOpenapiPaths>()

describe('ApiV1UsersController', () => {
beforeEach(async () => {
await request.init(PsychicServer)
})

describe('GET /api/v1/users/me', () => {
async function getSession() {
return await request.session('/api/v1/signin', 204, {
data: {
email: 'how@yadoin',
password: 'password',
},
})
}

it('returns 204 with an authed user', async () => {
await createUser({ email: 'how@yadoin', password: 'password' })
const session = await getSession()
await session.get('/api/v1/users/me').expect(204)
})

it('returns 401 with no authed user', async () => {
await createUser({ email: 'how@yadoin', password: 'password' })
await request.get('/api/v1/users/me', 401)
})
})
})

Feature (end-to-end) specs

Psychic will automatically bootstrap with puppeteer as a dev dependency to provide a headless browser you can use to test a web application end-to-end. Whenever these tests run, a psychic server will automatically be started which can be used by your client application, enabling you to test your front end integration with your back end.

For more information, see The Feature spec guides.

// api/spec/features/visitor/signs-up.spec.ts

import User from '../../../src/app/models/User'

describe('visitor visits the signup page', () => {
it('allows visitor to fill sign up for a new account and then log in with the same credentials', async () => {
await visit('/signup')
await fillIn('#email', 'hello@world')
await fillIn('#password', 'mypassword')
await clickButton('sign up')

await expect(page).toMatchTextContent('Log in')
await fillIn('#email', 'hello@world')
await fillIn('#password', 'mypassword')
await clickButton('log in')

await expect(page).toMatchTextContent('DASHBOARD')

const user = await User.last()
expect(user.email).toEqual('hello@world')
expect(await user.checkPassword('mypassword')).toEqual(true)
})
})
tip

Wondering how to get started? A good place to go next is our installation guide.

Otherwise, you may want to read up more on some of the major features provided by Dream and Psychic: