npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

typeorm-foundation

v0.8.0

Published

Base repository and entity validation building blocks for TypeORM.

Readme

typeorm-foundation

This is an opinionated library to provide the missing pieces in daily life with typeorm.

It provides a foundation repository and entity validation building blocks for TypeORM: a class-validator-backed validation pipeline wired into insert/update/upsert, plus a repository extension with safe upserts and a way to add your own methods to every repository the factory creates.

Install

pnpm add typeorm-foundation

Peer dependencies (bring your own versions): typeorm, class-validator.

Quick start

import { DataSource } from 'typeorm';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { IsEmail } from 'class-validator';
import { createRepositoryFactory, isDirty, IsUnique } from 'typeorm-foundation';

@Entity('users')
class UserEntity {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column({ type: 'text' })
  @IsEmail()
  @IsUnique({ validateIf: (user) => isDirty(user, 'email') })
  email!: string;

  constructor(values: Partial<UserEntity> = {}) {
    Object.assign(this, values);
  }
}

const dataSource = new DataSource({ /* ... */ });
const createFoundationRepository = createRepositoryFactory();
const UserRepository = createFoundationRepository(dataSource.getRepository(UserEntity));

const user = await UserRepository.insertEntity(new UserEntity({ email: '[email protected]' }));
await UserRepository.updateEntity(user, { email: '[email protected]' });

A repository built this way validates on every insertEntity/updateEntity/upsertEntity call and throws a ValidationError (one message per invalid field) if any decorator fails.

createRepositoryFactory

Returns createFoundationRepository(repository), which takes a plain TypeORM Repository and extends it with everything below. No DataSource is needed — the entity target, metadata and data source all come from the repository you hand in.

The optional argument is a plain object of extra methods, added onto every repository the factory creates. Inside those methods, this is typed as the full repository — the underlying TypeORM Repository, every built-in method below, and every other extension method — so they can call this.createQueryBuilder(...), this.insertEntity(...), this.manager, or each other:

export const createFoundationRepository = createRepositoryFactory({
  async findManyByIds(ids: string[]) {
    return this.createQueryBuilder(this.metadata.tableName)
      .whereInIds(ids)
      .getMany();
  },
});

export const UserRepository = createFoundationRepository(AppDataSource.getRepository(UserEntity));

Pass nothing and every repository is just the built-ins below, with no extras.

Since one extensions object is shared across every repository, this inside those methods is typed against a generic entity rather than the specific one a given repository is for. Anything that needs the concrete entity type belongs on the individual repository instead — either via TypeORM's own .extend(...) on the result, or via override(...), where this is entity-specific:

export const UserRepository = createFoundationRepository(AppDataSource.getRepository(UserEntity)).extend({
  async findByEmail(email: string) {
    return this.findOne({ where: { email } });   // `this` is entity-specific here
  },
});

Each repository built from it is a TypeORM Repository<Entity> extended with:

  • insertEntity(entity) — validates, then inserts.
  • updateEntity(entity, updates) — validates, applies updates, runs beforeUpdate/afterUpdate listeners, and issues a single UPDATE only for columns that actually changed. No-ops (skipping validation and listeners) when updates is empty. Automatically bumps any isUpdateDate column not explicitly included in updates.
  • upsertEntity(entity, { updates, key? }) — validates, then runs INSERT ... ON CONFLICT (key) DO UPDATE ... RETURNING *, hydrating the returned row (including any column transformers) back onto entity. updates is either a list of property names to write or an object of values to merge onto the entity first. Needs a RETURNING-capable driver (only tested against Postgres here).
  • upsertOrFailBy(findCondition, updates) — updateEntity if a row matching findCondition exists, otherwise insertEntity.
  • removeEntity(entity) — removes a clone of entity so the original object (and its primary key) is left untouched.
  • reload(entity) — re-fetches entity by primary key, throwing NotFoundError if it's gone.
  • override(methods) — assigns methods onto one repository instance (mutating and returning this). Unlike the factory's extensions, this here is typed against the concrete entity, because it runs on an already-built repository. Use it for anything needing real entity types, and the factory's extensions for methods every repository should have.
  • validateEntityOrFail(entity, fields) — no-op by default; override per repository (via override(...)) to add validation beyond what decorators express. Called with fields: null on insert/upsert and the list of changed keys on update.

FoundationRepository<Entity> is the type of what createFoundationRepository(repository) returns — useful for typing a function that accepts one of these repositories, or a class of your own that wraps one.

Errors

Everything this library throws extends FoundationError, an abstract Error subclass, so catch (error) { if (error instanceof FoundationError) ... } catches anything this library raises, as opposed to an error from your own code or a dependency. Being abstract, FoundationError can't be constructed directly — throw one of the concrete errors below (ValidationError is the one you'd normally raise from your own validateEntityOrFail override).

  • ArgumentError — thrown when a call into the repository is malformed rather than the data being invalid: upsertEntity with an empty updates list or with an updates/key entry that maps to no column, and updateEntity/reload on an entity whose class declares no primary key or whose primary key isn't set.
  • MissingValidationContextError — thrown when something that needs the validation context runs outside it, i.e. isNew/isChanged/isDirty or one of the ValidateWith/References/IsUnique decorators called outside a validateOrFail run.
  • NotFoundError — thrown by reload when the entity no longer exists.
  • ValidationError — thrown by validateOrFail (and so by insertEntity/updateEntity/upsertEntity); error.errors is a Record<field, string[]> of every failing message, grouped by property. Its constructor also accepts a plain string (new ValidationError('something went wrong')), filed under the base key, for a validation failure that isn't tied to one field — e.g. from your own validateEntityOrFail override.

Use plain instanceof to narrow a caught unknown to one of these errors. It is safe even under dual module loading: each error class is registered on globalThis under a version-scoped Symbol.for(...) key, so the ESM and CJS builds of this package — or two copies of it in one dependency tree — resolve to the very same class object, and an error thrown through one import matches instanceof through the other. (Genuinely different versions of the package get different keys, and so stay separate classes, as they should.)

For example, reload throws NotFoundError when the row has been deleted since the entity was loaded, which is usually a case you want to handle rather than propagate:

import { NotFoundError } from 'typeorm-foundation';

async function refreshUser(user: UserEntity) {
  try {
    return await UserRepository.reload(user);
  } catch (error) {
    if (!(error instanceof NotFoundError)) throw error;

    // Someone deleted the row in the meantime.
    return null;
  }
}

Re-throwing anything the check rejects keeps unrelated failures (a dropped connection, an ArgumentError from an unset primary key) from being swallowed as a missing row.

insertEntity, updateEntity and upsertEntity validate before writing and throw ValidationError if any decorator on the entity fails, so nothing reaches the database. Catch it to turn a failed write into a per-field response:

import { ValidationError } from 'typeorm-foundation';

try {
  await UserRepository.insertEntity(new UserEntity({ email: 'not-an-email' }));
} catch (error) {
  if (!(error instanceof ValidationError)) throw error;

  error.errors;  // { email: ['must be an email'] }
  error.message; // 'email: must be an email'
}

errors holds every failing message, grouped by property, with the leading property name stripped from each message so you can render it next to your own field label. message is those same entries flattened into one string.

error.name and ValidationError's errors are readonly.

Validation

Additional decorators build on class-validator. class-validator doesn't allow passing a context, so validateOrFail sets up an AsyncLocalStorage context that gives decorators access to the transactional entity manager, which insertEntity/updateEntity/upsertEntity set up automatically.

The decorators below query the database, so they must not see values that class-validator has already rejected — a non-uuid string reaching a uuid column makes the driver raise instead of the validation failing cleanly. validateOrFail therefore validates in two passes: the first runs the standard class-validator decorators, the second runs the ones below, each skipped unless every property it depends on survived the first pass. A decorator depends on the property it is attached to, plus IsUnique's scope columns, plus anything named in its dependencies option — declare that whenever a callback reads other properties off the entity:

@Column({ type: 'text' })
@ValidateWith<BookingEntity, 'endsAt'>((value, booking) =>
  value < booking.startsAt ? 'must be after the start' : undefined, { dependencies: ['startsAt'] })
endsAt!: Date;

Without that, endsAt would be checked against a startsAt that the first pass had already rejected.

The second pass only has something to check if the properties it depends on carry standard decorators, so give every property with a decorator from this library the type validation its column needs — otherwise nothing can fail in the first pass and the value reaches the query unchecked:

@Column({ type: 'text', nullable: true })
@IsOptional()
@IsUUID()
@References<UserEntity, 'teamId', TeamEntity>(() => TeamEntity)
teamId!: string | null;
  • ValidateWith(validate, { dependencies? }) — property decorator; validate(value, entity, entityManager) returns an error string (or a Promise of one) to fail, undefined to pass. The returned string is the message, so there is no separate message option — to reuse a shared predicate with a per-property message, wrap it: ValidateWith((value) => isReserved(value) ? 'is not allowed' : undefined).

  • References(() => RelatedEntity, { foreignKey?, primaryKey?, validate?, validateIf?, dependencies?, message? }) — fails unless a RelatedEntity row exists whose primaryKey columns equal this entity's foreignKey columns, or if any foreignKey column is null/undefined. foreignKey defaults to the decorated property and primaryKey to RelatedEntity's primary columns, in declaration order; both take a single property or an array, paired by position, so a composite key is just two arrays. primaryKey can name other columns than the real primary key, but they must be unique together: nothing checks that, and on duplicates the lookup picks an arbitrary row, so validate may see the wrong one. foreignKey must include the decorated property, and every foreignKey column is a dependency, so an invalid one skips the lookup. A misconfigured decorator throws ArgumentError: arrays of different lengths or a foreignKey without the decorated property when the class is defined, a length mismatch with the default primaryKey or a related entity without primary key on the first validation, even if validateIf or a null value would skip the lookup. Only the primaryKey columns are selected unless the optional validate(relatedEntity, entity) callback is given, which receives the full row and can reject further (e.g. a status check), returning an error string the same way ValidateWith does.

    @References<PaymentEntity, 'currencyCode', CurrencyEntity>(() => CurrencyEntity, { primaryKey: 'code' })
    currencyCode!: string;
    
    @References<AssignmentEntity, 'membershipId', MembershipEntity>(() => MembershipEntity, {
      foreignKey: ['organizationId', 'membershipId'],
      primaryKey: ['organizationId', 'id'],
    })
    membershipId!: string;
  • IsUnique({ scope?, caseInsensitive?, validateIf?, dependencies?, message? }) — fails if another row (excluding the entity's own primary key) already has this value, optionally scoped to a set of sibling columns. caseInsensitive makes the comparison ignore case by normalising both sides with SQL UPPER() ('upper') or LOWER() ('lower') — pick whichever matches a functional index you have, so the lookup can still use it. Left undefined (the default), the value is compared as-is. It applies to the decorated property only, not to scope columns, and only when the value is a string.

  • isNew(entity) / isChanged(entity, property) / isDirty(entity, property) — call from inside a validator (or an entity's own @ValidateIf) to check the entity against the pre-update snapshot: isNew is true when there is no snapshot (an insert), isChanged compares the property to the snapshot, isDirty is isNew(entity) || isChanged(entity, property).

  • validateOrFail({ entity, entityManager, original }) — runs both passes described above over entity and throws ValidationError (see Errors) if any decorator fails; original is the pre-update snapshot (or null for an insert) that isNew/isChanged/isDirty read from. Standard class-validator decorators run in both passes, so keep your own custom decorators free of side effects, or give them a validateIf that makes the second run cheap.

Please note: typeorm has no real dirty tracking. Therefore, when using insertEntity everything is assumed to be changed/dirty and isNew returns true. When using updateEntity, isChanged/isDirty compare each property to the pre-update snapshot by reference (!==), so an object or array passed as a new instance counts as changed even when its contents are equal, while one mutated in place and passed back as the same instance does not.

Testing

Tests run against a real database rather than a mocked DataSource. Postgres is the default; start it (and MySQL, for the DATABASE=mysql run) with docker compose up -d, then pnpm test. Switch database with the DATABASE env var:

pnpm test                 # postgres (localhost:5544, docker compose)
DATABASE=mysql pnpm test  # mysql (localhost:3306, docker compose)
DATABASE=sqlite pnpm test # better-sqlite3, in-memory, no service needed

upsertEntity relies on RETURNING, which MySQL and SQLite don't support (only MariaDB does, not plain MySQL) — its tests are skipped outside of DATABASE=postgres. CI runs the suite against all three.