Getting Started ​
Ready to use Models in your project? This guide will walk you through installation, basic usage, and common patterns to get you productive quickly.
Installation ​
Requirements ​
TypeScript: Version 5.3.0 or higher is required. We recommend using the latest stable version for the best experience and type safety.
Configuration: Make sure you have "strict": true
(or at least "strictNullChecks": true
) enabled in your tsconfig.json
:
{
"compilerOptions": {
"strict": true
}
}
Install the Package ​
You can install @uni-ts/model
using your preferred package manager:
$ npm add @uni-ts/model
$ pnpm add @uni-ts/model
$ yarn add @uni-ts/model
$ bun add @uni-ts/model
If you want to handle model validation errors safely without throwing exceptions, you can also install @uni-ts/result
:
$ npm add @uni-ts/model @uni-ts/result
$ pnpm add @uni-ts/model @uni-ts/result
$ yarn add @uni-ts/model @uni-ts/result
$ bun add @uni-ts/model @uni-ts/result
Your First Model ​
As you saw in the Introduction, a Model combines data structure and business logic in one place. Let's create your first model using Zod:
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
const User = createModel(
z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0),
}),
);
// Get the TypeScript type
type User = InferModelOutput<typeof User>;
📦 Bring Your Own Validation Library
While all examples in the documentation use Zod, you can choose any validation library that supports Standard Schema instead.
Validation Methods ​
Models provide multiple ways to validate data, each suited for different scenarios:
Type Guards with is()
​
Use is()
to safely check unknown data and narrow its type:
import { User } from './user';
export function processUserData(data: unknown) {
if (User.is(data)) {
// TypeScript knows data is User type
console.log(`Processing user: ${data.name}`);
return data;
}
throw new Error('Invalid user data');
}
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
export type User = InferModelOutput<typeof User>;
export const User = createModel(
z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0),
}),
);
Validation with from()
​
When you have data that should match your model's input type, use from()
:
import { User } from './user';
const user = User.from({
name: 'John Doe',
email: '[email protected]',
age: 25,
});
console.log(user.name); // "John Doe"
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
export type User = InferModelOutput<typeof User>;
export const User = createModel(
z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0),
}),
);
Validation with cast()
​
When working with completely unknown data (like API responses), use cast()
:
import { User } from './user';
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
// cast() validates unknown data
return User.cast(data);
}
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
export type User = InferModelOutput<typeof User>;
export const User = createModel(
z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0),
}),
);
đź’ˇ from
vs cast
Use from()
when you're confident about the data structure, and cast()
when working with external or unknown data sources.
Accessing the Schema ​
Every model exposes its underlying validation schema through the .schema
property. It's useful when you need to compose schemas or use them with other libraries.
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
import { Todo } from './todo';
export type TodoList = InferModelOutput<typeof TodoList>;
export const TodoList = createModel(
z.object({
id: z.int().positive(),
name: z.string().min(1),
todos: z.array(Todo.schema), // âś… Use the underlying schema
}),
);
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
export type Todo = InferModelOutput<typeof Todo>;
export const Todo = createModel(
z.object({
id: z.int().positive(),
title: z.string().min(1),
completed: z.boolean().default(false),
}),
);
import { TodoList } from './todo-list';
const todoList = TodoList.from({
id: 1,
name: 'My Tasks',
todos: [
{ id: 1, title: 'Learn TypeScript', completed: true },
{ id: 2, title: 'Build an app', completed: false },
],
});
Extending Models ​
Models become even more powerful when you integrate them with business rules. You can use the second argument when defining a model to add custom functionality.
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
export type Product = InferModelOutput<typeof Product>;
export const Product = createModel(
z.object({
name: z.string().min(1),
price: z.number().positive(),
stock: z.number().int().min(0),
}),
{
isInStock,
canBeSold,
},
);
function isInStock(product: Product) {
return product.stock > 0;
}
function canBeSold(product: Product, quantity: number) {
return product.stock >= quantity && quantity > 0;
}
import { Product } from './definition';
const laptop = Product.from({
name: 'Gaming Laptop',
price: 1299.99,
stock: 5,
});
Product.isInStock(laptop); // true
Product.canBeSold(laptop, 3); // true
Working with Different Libraries ​
Models work with any validation library that supports the Standard Schema interface. Here are examples for some popular libraries:
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
export type Product = InferModelOutput<typeof Product>;
export const Product = createModel(
z.object({
name: z.string().min(1),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'books']),
}),
);
import { createModel, type InferModelOutput } from '@uni-ts/model';
import * as v from 'valibot';
export type Product = InferModelOutput<typeof Product>;
export const Product = createModel(
v.object({
name: v.pipe(v.string(), v.minLength(1)),
price: v.pipe(v.number(), v.minValue(0)),
category: v.picklist(['electronics', 'clothing', 'books']),
}),
);
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { type } from 'arktype';
export type Product = InferModelOutput<typeof Product>;
export const Product = createModel(
type({
name: 'string>0',
price: 'number>0',
category: "'electronics'|'clothing'|'books'",
}),
);
All have the same interface regardless of the underlying validation library!
Branded Types for Primitives ​
When modeling primitive values like emails or user IDs, use branded types to enforce validation at the type level:
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
// Create branded type
type Email = InferModelOutput<typeof Email>;
const Email = createModel(z.email().brand('Email'));
// Function can now require already validated data
function sendWelcomeEmail(to: Email) {
// No need to validate - email is guaranteed to be valid
console.log(`Sending welcome email to ${to}`);
}
// Usage requires validation
const email = Email.from('[email protected]');
sendWelcomeEmail(email);
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
// Create branded type
type UserId = InferModelOutput<typeof UserId>;
const UserId = createModel(z.uuid().brand('UserId'));
// Function can now require already validated data
function getUser(id: UserId) {
// No need to validate - ID is guaranteed to be a valid UUID
return fetch(`/api/users/${id}`);
}
// Usage requires validation
const userId = UserId.from('123e4567-e89b-12d3-a456-426614174000');
getUser(userId);
Branded Types Across Libraries ​
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
export type Email = InferModelOutput<typeof Email>;
export const Email = createModel(z.email().brand('Email'));
export type UserId = InferModelOutput<typeof UserId>;
export const UserId = createModel(z.uuid().brand('UserId'));
import { createModel, type InferModelOutput } from '@uni-ts/model';
import * as v from 'valibot';
export type Email = InferModelOutput<typeof Email>;
export const Email = createModel(
v.pipe(v.string(), v.email(), v.brand('Email')),
);
export type UserId = InferModelOutput<typeof UserId>;
export const UserId = createModel(
v.pipe(v.string(), v.uuid(), v.brand('UserId')),
);
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { type } from 'arktype';
export type Email = InferModelOutput<typeof Email>;
export const Email = createModel(type('string.email#Email'));
export type UserId = InferModelOutput<typeof UserId>;
export const UserId = createModel(type('string.uuid#UserId'));
Safe Error Handling ​
If you prefer Result types over exceptions, use the safe model creators from @uni-ts/model/safe
. In such case, remember to have @uni-ts/result
installed in your project.
import { createSafeModel } from '@uni-ts/model/safe';
import { isOk } from '@uni-ts/result';
import { z } from 'zod';
const User = createSafeModel(
z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0),
}),
);
// Safe validation returns Result instead of throwing
const result = User.from({ name: 'John', email: 'invalid', age: 25 });
if (isOk(result)) {
console.log('User created:', result.data);
} else {
console.error('Validation failed:', result.error);
}
Safe Model Variants ​
@uni-ts/model/safe
provides three variants of safe model creators, so you can choose the one that fits your coding style.
import { createSafeModel } from '@uni-ts/model/safe';
import { z } from 'zod';
const User = createSafeModel(
z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0),
}),
);
// Only safe methods available
const user1 = User.from(data);
const user2 = User.cast(data);
import { createSafeFirstModel } from '@uni-ts/model/safe';
import { z } from 'zod';
const User = createSafeFirstModel(
z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0),
}),
);
// Safe by default, unsafe methods with prefix
const user1 = User.from(data);
const user2 = User.cast(data);
const user3 = User.unsafeFrom(data);
const user4 = User.unsafeCast(data);
import { createUnsafeFirstModel } from '@uni-ts/model/safe';
import { z } from 'zod';
const User = createUnsafeFirstModel(
z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0),
}),
);
// Unsafe by default, safe methods with prefix
const user1 = User.from(data);
const user2 = User.cast(data);
const user3 = User.safeFrom(data);
const user4 = User.safeCast(data);
Working with APIs ​
Models shine when working with external APIs. Here's a common pattern for usage with data fetching.
import { User } from './user';
import { fetchUser } from './user-service';
const user = await fetchUser('123e4567-e89b-12d3-a456-426614174000');
if (User.isNew(user)) {
console.log(`Welcome ${user.name}!`);
}
import { ApiUser } from './api-user';
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const apiData = await response.json();
// Validate API response
const apiUser = ApiUser.cast(apiData);
// Convert to domain model
return ApiUser.toUser(apiUser);
}
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
import { User } from './user';
// API response model
export type ApiUser = InferModelOutput<typeof ApiUser>;
export const ApiUser = createModel(
z.object({
id: z.uuid(),
first_name: z.string(),
last_name: z.string(),
email: z.email(),
created_at: z.number().int().min(0),
}),
{
toUser,
},
);
function toUser(apiUser: ApiUser) {
return User.from({
id: apiUser.id,
name: `${apiUser.first_name} ${apiUser.last_name}`,
email: apiUser.email,
createdAt: new Date(apiUser.created_at),
});
}
import { createModel, type InferModelOutput } from '@uni-ts/model';
import { z } from 'zod';
// Domain model with business logic
export type User = InferModelOutput<typeof User>;
export const User = createModel(
z.object({
id: z.uuid().brand('UserId'),
name: z.string().min(1),
email: z.email().brand('Email'),
createdAt: z.date(),
}),
{
isNew,
},
);
function isNew(user: User) {
const daysSinceCreation =
(Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24);
return daysSinceCreation <= 7;
}