Skip to content

Model

The classes can be used as a model in your application. Ts.ED uses these models to convert JSON objects to their class equivalents.

The classes models can be used in the following cases:

  • Data serialization and deserialization with the (Json mapper),
  • Data validation with AJV or any library compatible with JsonSchema,
  • Generating documentation with Swagger.

To create a model, Ts.ED provides decorators which will store and generate a standard JsonSchema model.

WARNING

Validation is only available when you import @tsed/ajv package in your server.

typescript
import {Configuration} from "@tsed/di";
import "@tsed/ajv";

@Configuration()
class Server {}

Without this package, decorators like Email won't have any effect.

See Validation for more information. By default, Ts.ED CLI install @tsed/ajv module.

Example

The example below uses decorators to describe a property of the class and store metadata such as the description of the field.

ts
import {Default, Enum, Format, Maximum, MaxLength, Minimum, MinLength, Pattern, Required} from "@tsed/schema";

enum Categories {
  CAT1 = "cat1",
  CAT2 = "cat2"
}

export class MyModel {
  _id: string;

  @Required()
  unique: string;

  @MinLength(3)
  @MaxLength(50)
  indexed: string;

  @Minimum(0)
  @Maximum(100)
  @Default(0)
  rate: Number = 0;

  @Enum(Categories)
  // or @Enum("type1", "type2")
  category: Categories;

  @Pattern(/[a-z]/)
  pattern: String;

  @Format("date-time")
  @Default(Date.now)
  dateCreation: Date = new Date();
}

TIP

The Model will generate a JsonSchema which can be used by modules supporting JsonSchema spec

WARNING

The schema generated by Ts.ED lists only properties decorated by at least one decorator. In the previous example, the _id won't be displayed in the JsonSchema. It's very important to understand that TypeScript only generates metadata on properties with at least one of these decorators:

Loading in progress...

Our model is now described, we can use it inside a Controller as input type parameter for our methods. Ts.ED will use the model to convert the raw data to an instance of your model.

ts
import {BodyParams} from "@tsed/platform-params";
import {Post} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {PersonModel} from "../models/PersonModel";

@Controller("/")
export class PersonsCtrl {
  @Post("/")
  save(@BodyParams() model: PersonModel): PersonModel {
    console.log(model instanceof PersonModel); // true
    return model; // will be serialized according to your annotation on PersonModel class.
  }

  // OR

  @Post("/")
  save(@BodyParams("person") model: PersonModel): PersonModel {
    console.log(model instanceof PersonModel); // true
    return model; // will be serialized according to your annotation on PersonModel class.
  }
}

Primitives

Just use at least Property decorator any other schema decorator (like Email), to create a new property on a model. Ts.ED will get the type from Typescript metadata and transform this type to a valid Json type.

ts
import {Default, getJsonSchema, Maximum, Minimum, Property} from "@tsed/schema";

export class Model {
  _id: string; // Won't be displayed on the Json schema

  @Property()
  prop1: string; // Displayed with the right type

  @Minimum(0)
  @Maximum(100)
  @Default(0)
  prop2: number = 0;
}

console.log(getJsonSchema(Model));
json
{
  "definitions": {},
  "properties": {
    "prop1": {
      "type": "string"
    },
    "prop2": {
      "default": 0,
      "maximum": 100,
      "minimum": 0,
      "type": "number"
    }
  },
  "type": "object"
}

Integer

The Integer decorator is used to set integer type for integral numbers.

ts
import {Integer} from "@tsed/schema";

export class Model {
  @Integer()
  prop: number;
}
json
{
  "definitions": {},
  "properties": {
    "prop": {
      "type": "integer"
    }
  },
  "type": "object"
}

Any types

The Any, decorator is used to set one or more types on property. Use this method when you want to set explicitly the json type or when you use a mixed TypeScript types.

ts
import {Any} from "@tsed/schema";

export class Model {
  @Any()
  prop1: any;

  @Any("string", "number", "boolean")
  prop2: string | number | boolean; // mixed type

  @Any(String, null)
  prop3: string | null;
}
json
{
  "definitions": {},
  "properties": {
    "prop1": {
      "type": ["integer", "number", "string", "boolean", "array", "object", "null"]
    },
    "prop2": {
      "type": ["string", "number", "boolean"]
    },
    "prop3": {
      "type": ["string", "null"]
    }
  },
  "type": "object"
}

You can also use Any decorator to allow all types:

typescript
import {Any} from "@tsed/schema";

export class Model {
  @Any()
  prop: any;
}
json
{
  "properties": {
    "prop": {
      "anyOf": [
        {
          "type": "null"
        },
        {
          "type": "integer",
          "multipleOf": 1
        },
        {
          "type": "number"
        },
        {
          "type": "string"
        },
        {
          "type": "boolean"
        },
        {
          "type": "array"
        },
        {
          "type": "object"
        }
      ]
    }
  },
  "type": "object"
}
json
{
  "properties": {
    "prop": {
      "nullable": true,
      "anyOf": [
        {
          "type": "integer",
          "multipleOf": 1
        },
        {
          "type": "number"
        },
        {
          "type": "string"
        },
        {
          "type": "boolean"
        },
        {
          "type": "array"
        },
        {
          "type": "object"
        }
      ]
    }
  },
  "type": "object"
}

Since v7.75.0, when you use Any decorator combined with other decorators like MinLength, Minimum, etc. metadata will be automatically assigned to the right type. For example, if you add a Minimum decorator, it will be assigned to the number type.

ts
import {Any} from "@tsed/schema";

class Model {
  @Any(String, Number)
  @Minimum(0)
  @MaxLength(100)
  prop: string | number;
}

Produce a json-schema as follows:

json
{
  "properties": {
    "prop": {
      "allOf": [
        {
          "type": "string",
          "maxLength": 100
        },
        {
          "type": "number",
          "minimum": 0
        }
      ]
    }
  },
  "type": "object"
}

Nullable

The Nullable decorator is used allow a null value on a field while preserving the original Typescript type.

ts
import {Nullable, Required} from "@tsed/schema";
import {MyModel, MyModel2} from "./MyModel";

export class Model {
  @Required(true, null) // allow null
  @Nullable(String)
  prop2: string | null;

  // can be used with models (JsonSchema and OS3 only)
  @Nullable(MyModel, MyModel2)
  prop3: MyModel | MyModel2 | null;
}
json
{
  "properties": {
    "prop2": {
      "type": ["null", "string"]
    }
  },
  "required": ["prop2"],
  "type": "object"
}

WARNING

Since the v7.43.0, ajv.returnsCoercedValues is available to solve the following issue: #2355 If returnsCoercedValues is true, AjvService will return the coerced value instead of the original value. In this case, @Nullable() will be mandatory to allow the coercion of the value to null.

For example if returnsCoercedValues is false (default behavior), Ts.ED will allow null value on a field without @Nullable() decorator:

typescript
class NullableModel {
  @Property()
  propString: string; // null => null

  @Property()
  propNumber: number; // null => null

  @Property()
  propBool: boolean; // null => null
}

Ajv won't emit validation error if the value is null due to his coercion behavior. AjvService will return the original value and not the Ajv coerced value. Another problem is, the typings of the model doesn't reflect the real coerced value.

Using the returnsCoercedValues option, AjvService will return the coerced type. In this case, our previous model will have the following behavior:

typescript
class NullableModel {
  @Property()
  propString: string; // null => ''

  @Property()
  propNumber: number; // null => 0

  @Property()
  propBool: boolean; // null => false
}

Now @Nullable usage is mandatory to allow null value on properties:

typescript
class NullableModel {
  @Nullable(String)
  propString: string | null; // null => null

  @Nullable(Number)
  propNumber: number | null; // null => null

  @Nullable(Boolean)
  propBool: boolean | null; // null => null
}

WARNING

returnsCoercedValue will become true by default in the next major version of Ts.ED.

Nullable and mixed types 7.75.0+

The Nullable decorator can be used with Tuple types:

ts
import {Nullable} from "@tsed/schema";

class Model {
  @Nullable(String, Number)
  prop: string | number | null;
}

Since v7.75.0, when you use Nullable decorator combined with other decorators like MinLength, Minimum, etc. metadata will be automatically assigned to the right type. For example, if you add a Minimum decorator, it will be assigned to the number type.

ts
import {Nullable} from "@tsed/schema";

class Model {
  @Nullable(String, Number)
  @Minimum(0)
  @MaxLength(100)
  prop: string | number | null;
}

Produce a json-schema as follows:

json
{
  "properties": {
    "prop": {
      "anyOf": [
        {
          "type": "null"
        },
        {
          "type": "string",
          "maxLength": 100
        },
        {
          "type": "number",
          "minimum": 0
        }
      ]
    }
  },
  "type": "object"
}

Regular expressions

The Pattern decorator is used to restrict a string to a particular regular expression. The regular expression syntax is the one defined in JavaScript (ECMA 262 specifically). See Regular Expressions for more information.

ts
import {Pattern} from "@tsed/schema";

export class Model {
  @Pattern(/^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$/)
  phone: string;
}
json
{
  "definitions": {},
  "properties": {
    "phone": {
      "pattern": "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$",
      "type": "string"
    }
  },
  "type": "object"
}

Format

The Format decorator allows basic semantic validation on certain kinds of string values that are commonly used. This allows values to be constrained beyond what the other tools in JSON Schema, including Regular Expressions can do.

ts
import {Email, Format} from "@tsed/schema";

export class Model {
  @Email()
  email: string;

  @Format("date-time")
  dateCreation: Date = new Date();
}
json
{
  "definitions": {},
  "properties": {
    "email": {
      "format": "email",
      "type": "string"
    },
    "dateCreation": {
      "format": "date-time",
      "type": "string"
    }
  },
  "type": "object"
}

The following formats are supported for string validation with format keyword by AJV:

  • date: full-date according to RFC3339.
  • time: time with optional time-zone.
  • date-time: date-time from the same source (time-zone is mandatory).
  • uri: full uri with optional protocol.
  • email: email address.
  • hostname: host name according to RFC1034.
  • ipv4: IP address v4.
  • ipv6: IP address v6.
  • regex: tests whether a string is a valid regular expression by passing it to RegExp constructor.

See built-in formats types on Jsonp-schema.org for more details:

MultipleOf

Numbers can be restricted to a multiple of a given number, using the MultipleOf decorator. It may be set to any positive number. See json-schema documentation for more details.

ts
import {MultipleOf} from "@tsed/schema";

export class Model {
  @MultipleOf(10)
  prop: number;
}
json
{
  "definitions": {},
  "properties": {
    "prop": {
      "multipleOf": 10,
      "type": "number"
    }
  },
  "type": "object"
}

Ranges

Ranges of numbers are specified using a combination of the Minimum and Maximum decorators, (or ExclusiveMinimum and ExclusiveMaximum for expressing exclusive range). See json-schema documentation for more details.

ts
import {ExclusiveMaximum, Minimum} from "@tsed/schema";

export class Model {
  @Minimum(0)
  @ExclusiveMaximum(100)
  prop: number;
}
json
{
  "definitions": {},
  "properties": {
    "prop": {
      "exclusiveMaximum": 100,
      "minimum": 0,
      "type": "number"
    }
  },
  "type": "object"
}

Enumerated values

The Enum decorator is used to restrict a value to a fixed set of values. It must be an array with at least one element, where each element is unique or a TypeScript enum.

ts
import {Any, CollectionOf, Enum} from "@tsed/schema";

export enum Colors {
  RED = "red",
  AMBER = "amber",
  GREEN = "green"
}

export enum Days {
  MONDAY = 0,
  TUESDAY,
  WEDNESDAY,
  THURSDAY,
  FRIDAY,
  SATURDAY,
  SUNDAY
}

export class Model {
  @Enum("red", "amber", "green")
  prop1: string;

  @Enum(Colors)
  prop2: Colors;

  @Enum(Days)
  @CollectionOf(Number)
  prop3: Days[];

  @Enum("red", "amber", "green", null, 42)
  @Any("string", "number", "null") // in v6 not required
  prop4: string | number | null;
}
json
{
  "definitions": {},
  "properties": {
    "prop1": {
      "enum": ["red", "amber", "green"],
      "type": "string"
    },
    "prop2": {
      "enum": ["red", "amber", "green"],
      "type": "string"
    },
    "prop3": {
      "enum": [0, 1, 2, 3, 4, 5, 6],
      "type": "number"
    },
    "prop4": {
      "enum": ["red", "amber", "green", 42, null],
      "type": "object"
    }
  },
  "type": "object"
}

Enum decorator can be also in combination with BodyParams or @QueryParams@@:

typescript
import {Enum} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {QueryParams} from "@tsed/platform-params";

@Controller("/")
class MyController {
  @Post("/")
  async method(@QueryParams("type") @Enum(MyEnum) type: MyEnum): Promise<any> {
    return null;
  }
}

Set label to an enum 7.17.0+

With OpenSpec 3 it's now possible to create shared enum for many models in components.schemas instead of having its inlined values in each model.

Ts.ED introduce a new function enums() to declare the enum schema as follows:

ts
import {enums} from "@tsed/schema";

enum ProductTypes {
  ALL = "ALL",
  ASSETS = "ASSETS",
  FOOD = "FOOD"
}

enums(ProductTypes).label("ProductTypes");

// in models
class Product {
  @Property()
  title: string;

  @Enum(ProductTypes)
  type: ProductTypes;
}

// in controller

import {Enum} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {QueryParams} from "@tsed/platform-params";

@Controller("/products")
class ProductsController {
  @Get("/:type")
  @(Returns(200, Array).Of(Product))
  async get(@PathParams("type") @Enum(ProductTypes) type: ProductTypes): Promise<Product> {
    return [new Product()];
  }
}

Constant values

The Const decorator is used to restrict a value to a single value. For example, if you only support shipping to the United States for export reasons:

ts
import {Const} from "@tsed/schema";

export class Model {
  @Const("United States of America")
  readonly country: string = "United States of America";
}
json
{
  "definitions": {},
  "properties": {
    "country": {
      "const": "United States of America",
      "type": "string"
    }
  },
  "type": "object"
}

Collections

Declaring a property that uses a collection is a bit different than declaring a simple property. TypeScript stores only the Array/Set/Map type when you declare the type of your property. The type used by the collection is lost.

To tell Ts.ED (and other third party which uses JsonSchema) that a property uses a collection with a specific type, you must use CollectionOf (before v5.62.0, use PropertyType) decorator as following:

ts
import {CollectionOf, getJsonSchema} from "@tsed/schema";
import {Model} from "./primitives";
import {Role} from "./Role";
import {Security} from "./Security";

class User {
  @CollectionOf(Role)
  roles: Role[];

  @CollectionOf(Security)
  securities: Map<string, Security>;

  @CollectionOf(String)
  scopes: Set<string>;
}

console.log(getJsonSchema(Model));
json
{
  "type": "object",
  "properties": {
    "roles": {
      "type": "array",
      "items": {
        "$ref": "#/definitions/Role"
      }
    },
    "securities": {
      "type": "object",
      "additionalProperties": {
        "$ref": "#/definitions/Security"
      }
    },
    "scopes": {
      "type": "array",
      "items:": {
        "type": "string"
      }
    }
  },
  "definitions": {
    "Security": {
      "type": "object"
    },
    "Role": {
      "type": "object"
    }
  }
}

Ts.ED provides others related collection decorators:

Loading in progress...

Required properties

By default, the properties defined with a decorator are not required. However, one can use Required decorator to add a required property to the json schema:

typescript
import {Required} from "@tsed/schema";

class MyModel {
  id: string;

  @Required()
  prop1: string;
}

You can also add a custom ajv error message with the .Error(msg) function:

typescript
import {Required} from "@tsed/schema";

class MyModel {
  id: string;

  @(Required().Error("custom message"))
  prop1: string;
}

Custom AJV error messages

If you don't like AJV's default error messages, you can customize them with these decorators:

DefaultMsg

This is a class decorator DefaultMsg that is used to define a default message as the name suggests:

typescript
import {DefaultMsg} from "@tsed/schema";

@DefaultMsg("an error occurred")
class MyModel {
  id: string;

  prop1: string;
}

TypeError

This is a property decorator TypeError that is used to define a custom error message for a specific type:

typescript
import {TypeError} from "@tsed/schema";

class MyModel {
  id: string;

  @TypeError("prop1 should be a string")
  prop1: string;
}

ErrorMsg

If none of the above work for you, you can use the ErrorMsg decorator to define your own custom error message schema using the ajv-errors documentation:

typescript
import {ErrorMsg} from "@tsed/schema";

class MyModel {
  id: string;

  @ErrorMsg({type: "prop1 should be a string"})
  prop1: string;
}

Additional properties

Sometimes, it can be useful to create model with additional properties. By default, Json schema is strict over extra properties not declared in a model ( see Properties json schema documentation).

Use AdditionalProperties on your model to allow this behavior:

ts
import {AdditionalProperties, Property} from "@tsed/schema";

@AdditionalProperties(true)
export class Model {
  @Property()
  id: string;

  [key: string]: any;
}
json
{
  "definitions": {},
  "properties": {
    "id": {
      "type": "string"
    }
  },
  "additionalProperties": true,
  "type": "object"
}

It is also possible to add constraint on additional properties, by giving a raw Json schema:

ts
import {AdditionalProperties, Property} from "@tsed/schema";

@AdditionalProperties({type: "string"})
export class Model {
  @Property()
  id: string;

  [key: string]: string;
}
json
{
  "definitions": {},
  "properties": {
    "id": {
      "type": "string"
    }
  },
  "additionalProperties": {
    "type": "string"
  },
  "type": "object"
}

Or by using getJsonSchema in combination with AdditionalProperty as following:

ts
import {AdditionalProperties, Property} from "@tsed/schema";

class AnotherModel {
  @Property()
  name: string;
}

@AdditionalProperties(AnotherModel)
export class Model {
  [key: string]: AnotherModel;
}
json
{
  "definitions": {},
  "additionalProperties": {
    "definitions": {},
    "properties": {
      "name": {
        "type": "string"
      }
    },
    "type": "object"
  },
  "type": "object"
}

Circular ref

Circular reference can be resolved by using arrow with a Property and CollectionOf decorators:

ts
import {CollectionOf, Groups, Property} from "@tsed/schema";

export class Photo {
  @Property(() => User)
  owner: User;
}

export class User {
  @CollectionOf(Photo)
  @Groups("group.roles")
  photos: Photo[];
}

Custom Keys

Ts.ED introduces the Keyword decorator to declare a new custom validator for Ajv. Combined with the CustomKey decorator to add keywords to a property of your class, you can use more complex scenarios than what basic JsonSchema allows.

For example, we can create a custom validator to support the range validation over a number. To do that, we have to define the custom validator by using Keyword decorator:

typescript
import {Keyword, KeywordMethods} from "@tsed/ajv";
import {array, number} from "@tsed/schema";

@Keyword({
  keyword: "range",
  type: "number",
  schemaType: "array",
  implements: ["exclusiveRange"],
  metaSchema: array().items([number(), number()]).minItems(2).additionalItems(false)
})
class RangeKeyword implements KeywordMethods {
  compile([min, max]: number[], parentSchema: any) {
    return parentSchema.exclusiveRange === true ? (data: any) => data > min && data < max : (data: any) => data >= min && data <= max;
  }
}

Then we can declare a model using the standard decorators from @tsed/schema:

typescript
import {CustomKey} from "@tsed/schema";
import {Range, ExclusiveRange} from "../decorators/Range"; // custom decorator

export class Product {
  @CustomKey("range", [10, 100])
  @CustomKey("exclusiveRange", true)
  price: number;

  // OR

  @Range(10, 100)
  @ExclusiveRange(true)
  price2: number;
}
typescript
import {CustomKey} from "@tsed/schema";

export function Range(min: number, max: number) {
  return CustomKey("range", [min, max]);
}

export function ExclusiveRange(bool: boolean) {
  return CustomKey("exclusiveRange", bool);
}

Finally, we can create a unit test to verify if our example works properly:

typescript
import "@tsed/ajv";
import {PlatformTest} from "@tsed/platform-http/testing";
import {getJsonSchema} from "@tsed/schema";
import {Product} from "./Product";
import "../keywords/RangeKeyword";

describe("Product", () => {
  beforeEach(PlatformTest.create);
  afterEach(PlatformTest.reset);

  it("should call custom keyword validation (compile)", () => {
    const ajv = PlatformTest.get<Ajv>(Ajv);
    const schema = getJsonSchema(Product, {customKeys: true});
    const validate = ajv.compile(schema);

    expect(schema).to.deep.equal({
      properties: {
        price: {
          exclusiveRange: true,
          range: [10, 100],
          type: "number"
        }
      },
      type: "object"
    });

    expect(validate({price: 10.01})).toEqual(true);
    expect(validate({price: 99.99})).toEqual(true);
    expect(validate({price: 10})).toEqual(false);
    expect(validate({price: 100})).toEqual(false);
  });
});

Ignore

deprecated

The Ignore decorator is used to ignore a property in the JsonSchema generation and when you use the json-mapper.

But this decorator is deprecated and will be removed in the next major version of Ts.ED. Instead, use Groups decorator to manage your model serialization/deserialization.

Note

To retrieve the original Ignore decorator behavior, you can use the Groups decorator. You have to set jsonMapper.strictGroups to true also:

ts
@Configuration({
  jsonMapper: {
    strictGroups: true
  }
})

Groups

Groups decorator allows you to manage your serialized/deserialized fields by using group label. For example, with a CRUD controller, you can have many methods like POST, PUT, GET or PATCH to manage creation, update and read use cases for the exposed resource.

For the creation, you don't need to have the id field but for the update, you need to have it. With the previous version for Ts.ED, you had to create the model twice, one for the creation (without id) and another one for update and read (with id). Managing many models can be a pain point for the developer, this is why the Groups decorator exists.

For example, we have a User model with the following properties:

ts
import {CollectionOf, Groups, Required} from "@tsed/schema";

export class User {
  @Groups("!creation")
  id: string;

  @Required()
  firstName: string;

  @Required()
  lastName: string;

  @Required()
  @Groups("group.email", "creation")
  email: string;

  @Groups("creation")
  password: string;

  @CollectionOf(String)
  @Groups("group.roles")
  roles: string[];
}

Explanation:

  • !creation: This annotation indicates that the field will never be exposed when using the creation group.
  • group.email: This annotation indicates that the field will be exposed only if the group match with group.email or with a glob pattern like group.*.

So by using the deserialize function with the extra groups options, we can map data to the expected user instance:

typescript
import {deserialize} from "@tsed/json-mapper";

const result = deserialize<User>(
  {
    id: "id", // will be ignored because creation doesn't include `id` field
    firstName: "firstName",
    lastName: "lastName",
    email: "email@tsed.io",
    password: "password"
  },
  {type: User, groups: ["creation"]}
);

console.log(result); // User {firstName, lastName, email, password}
typescript
import {deserialize} from "@tsed/json-mapper";

const result = deserialize<User>(
  {
    id: "id",
    firstName: "firstName",
    lastName: "lastName",
    email: "email@tsed.io",
    password: "password",
    roles: ["admin"]
  },
  {type: User, groups: ["group.email"]}
);

console.log(result); // User {id, firstName, lastName, email, password}
typescript
import {deserialize} from "@tsed/json-mapper";

const result = deserialize<User>(
  {
    id: "id",
    firstName: "firstName",
    lastName: "lastName",
    email: "email@tsed.io",
    password: "password",
    roles: ["admin"]
  },
  {type: User, groups: ["group.*"]}
);

console.log(result); // User {id, firstName, lastName, email, password, roles}

Note

The same principle works with the serialize and getJsonSchema functions!

Now let's see how groups work with controllers.

ts
import {BodyParams, PathParams} from "@tsed/platform-params";
import {Get, Groups, Post, Returns} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {User} from "../models/User";

@Controller("/")
export class UsersCtrl {
  @Get("/:id")
  @Returns(200, User).Groups("group.*")
  async get(@PathParams("id") id: string) {}

  @Post("/")
  @Returns(201, User).Groups("group.*")
  post(@BodyParams() @Groups("creation") user: User) {
    console.log(user); // User {firstName, lastName, email, password}
    user.id = uuid();

    return user; // will return Object {id, firstName, lastName, email}
  }
}
ts
import {CollectionOf, Groups, Required} from "@tsed/schema";

export class User {
  @Groups("!creation")
  id: string;

  @Required()
  firstName: string;

  @Required()
  lastName: string;

  @Required()
  @Groups("group.email", "creation")
  email: string;

  @Groups("creation")
  password: string;

  @CollectionOf(String)
  @Groups("group.roles")
  roles: string[];
}
json
{
  "paths": {
    "/{id}": {
      "get": {
        "operationId": "usersCtrlGet",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserGroup"
                }
              }
            },
            "description": "Success"
          }
        },
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "tags": ["UsersCtrl"]
      }
    },
    "/": {
      "post": {
        "operationId": "usersCtrlPost",
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserUser"
                }
              }
            },
            "description": "Created"
          }
        },
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UserCreation"
              }
            }
          }
        },
        "tags": ["UsersCtrl"]
      }
    }
  },
  "tags": [
    {
      "name": "UsersCtrl"
    }
  ],
  "components": {
    "schemas": {
      "UserGroup": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "firstName": {
            "type": "string",
            "minLength": 1
          },
          "lastName": {
            "type": "string",
            "minLength": 1
          },
          "email": {
            "type": "string",
            "minLength": 1
          },
          "roles": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": ["firstName", "lastName", "email"]
      },
      "UserCreation": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "string",
            "minLength": 1
          },
          "lastName": {
            "type": "string",
            "minLength": 1
          },
          "email": {
            "type": "string",
            "minLength": 1
          },
          "password": {
            "type": "string"
          }
        },
        "required": ["firstName", "lastName", "email"]
      }
    }
  }
}

We can see that the Groups decorator can be used on parameter level as well as on the method through the Returns decorator. The generated OpenSpec will create automatically the appropriate JsonSchema according to the groups configuration!

TIP

You can combine different group labels or use a glob pattern to match multiple group labels. It's also possible to use negation by prefixing the group label with !.

Groups strict mode 7.69.0+

The Groups decorator has introduced a big change in the way it manages its models, but it can sometimes be complicated to understand its default behavior when the endpoint does not define Groups.

In addition, the documentation generated does not reflect the behavior observed in runtime, which adds confusion.

For example, we have our User model with the following properties and Groups configuration:

ts
export class User {
  @Groups("!creation")
  id: string;

  @Required()
  firstName: string;

  @Required()
  lastName: string;

  @Required()
  @Groups("group.email", "creation")
  email: string;

  @Groups("creation")
  password: string;
}

Now, we have this controller:

ts
class TestController {
  @Post("/")
  @Returns(200, User)
  async post(@BodyParams() user: User) {
    return user;
  }
}

We can see that the Returns and the input params doesn't set any group configuration. In this case, Ts.ED will not apply any group configuration to the input params and the output.

So if you send this payload:

json
{
  "id": "id",
  "firstName": "firstName",
  "lastName": "lastName",
  "email": "",
  "password": "password"
}

The endpoint will return the same payload without any modification. But here, we expect that the email and password fields are not returned because they are Groups configuration on these fields.

To avoid this behavior, you can set the strictGroups option to the json-mapper:

ts
@Configuration({
  jsonMapper: {
    strictGroups: true
  }
})

Now, if you send the same payload, the endpoint will return the following payload:

json
{
  "id": "id",
  "firstName": "firstName",
  "lastName": "lastName"
}

WARNING

The strictGroups option is enabled by default in the next major version of Ts.ED.

Groups Name

By default, Groups decorator generate automatically a name for each model impacted by the given groups list. If you use a typed client http generator based on Swagger (OAS3) to generate the client code, this behavior can be a constraint for your consumer when you change the group list.

ts
import {BodyParams, PathParams} from "@tsed/platform-params";
import {Get, Groups, Post, Returns} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {User} from "../models/User";

@Controller("/")
export class UsersCtrl {
  @Get("/:id")
  @(Returns(200, User).Groups("group.*"))
  async get(@PathParams("id") id: string) {}

  @Post("/")
  @(Returns(201, User).Groups("group.*"))
  async post(@BodyParams() @Groups("creation", "summary") user: User) {}
}

In this example, the Groups annotation @Groups("creation", "summary") user: User will generate a new model name UserCreationSummary. If you change the groups list by this one:

 @Groups("creation", "summary", "extra") user: User

The new model name will be UserCreationSummaryExtra. This change will break the entire consumer code by removing the UserCreationSummary type and giving a new UserCreationSummaryExtra type. In fact, UserCreationSummary and UserCreationSummaryExtra are the same model with more fields!

In order to minimize the impact of this kind of change Ts.ED allows to configure the postfix added to each model impacted by the groups.

Here is an example with a configured GroupsName:

ts
import {BodyParams, PathParams} from "@tsed/platform-params";
import {Get, Groups, Post, Returns} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {User} from "../models/User";

@Controller("/")
export class UsersCtrl {
  @Get("/:id")
  @(Returns(200, User).Groups("Details", ["group.*"]))
  async get(@PathParams("id") id: string) {}

  @Post("/")
  @(Returns(201, User).Groups("Details", ["group.*"]))
  async post(@BodyParams() @Groups("Creation", ["creation", "summary"]) user: User) {}
}

Now, @Groups("Creation", ["creation", "summary"]) user: User will generate a UserCreation type and @Returns(200, User).Groups("Details", ["group.*"]) will generate a UserDetails type.

Groups class definition

It's also possible to define all groups on class instead of declaring it on each property.

ts
import {BodyParams, PathParams} from "@tsed/platform-params";
import {Get, Groups, Post, Returns} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {User} from "../models/User";

@Controller("/")
export class UsersCtrl {
  @Get("/:id")
  @Returns(200, User).Groups("update")
  async get(@PathParams("id") id: string) {}

  @Post("/")
  @Returns(201, User).Groups("update")
  post(@BodyParams() @Groups("creation") user: User) {
    console.log(user);
    user.id = uuid();

    return user;
  }

  @Post("/change-password")
  @Returns(204)
  changePassword(@BodyParams() @Groups("changePassword") user: User) {
    console.log(user);
  }
}
ts
import {Groups, Required, RequiredGroups} from "@tsed/schema";

@Groups<User>({
  // will generate UserCreate
  create: ["firstName", "lastName", "email", "password"],
  // will generate UserUpdate
  update: ["id", "firstName", "lastName", "email"],
  // will generate UserChangePassword
  changePassword: ["id", "password", "newPassword"]
})
export class User {
  @Required()
  id: string;

  @RequiredGroups("creation")
  firstName: string;

  @RequiredGroups("creation")
  lastName: string;

  @RequiredGroups("creation")
  email: string;

  @RequiredGroups("create", "changePassword")
  password: string;

  @RequiredGroups("changePassword")
  newPassword: string;
}
json
{
  "properties": {
    "email": {
      "minLength": 1,
      "type": "string"
    },
    "firstName": {
      "minLength": 1,
      "type": "string"
    },
    "lastName": {
      "minLength": 1,
      "type": "string"
    },
    "password": {
      "type": "string"
    }
  },
  "required": ["firstName", "lastName", "email"],
  "type": "object"
}
json
{
  "properties": {
    "email": {
      "type": "string"
    },
    "firstName": {
      "type": "string"
    },
    "id": {
      "minLength": 1,
      "type": "string"
    },
    "lastName": {
      "type": "string"
    }
  },
  "required": ["id"],
  "type": "object"
}
json
{
  "properties": {
    "id": {
      "minLength": 1,
      "type": "string"
    },
    "newPassword": {
      "minLength": 1,
      "type": "string"
    },
    "password": {
      "minLength": 1,
      "type": "string"
    }
  },
  "required": ["id", "password", "newPassword"],
  "type": "object"
}

ForwardGroups

Groups configuration isn't forwarded to the nested models to avoid side effect on model generation. With ForwardGroups decorator, you are able to tell if a property should use or not the Groups configuration to generate correctly a nested model.

typescript
class ChildModel {
  @Groups("!creation")
  id: string;

  @Required()
  prop1: string;
}

class MyModel {
  @Groups("!creation")
  id: string;

  @Groups("group.summary")
  @Required()
  prop1: string;

  @Groups("group.extended")
  @Required()
  prop2: string;

  @Property()
  @Required()
  prop3: string;

  @CollectionOf(ChildModel)
  @ForwardGroups()
  prop4: ChildModel[];
}

Now prop4 will have a ChildModel generated along to groups configuration.

RequiredGroups

As Groups decorator, RequiredGroups allow you to define when a field is required depending on the given groups strategy.

The usage is the same as Groups:

typescript
import {RequiredGroups, Groups, Required} from "@tsed/schema";

class MyModel {
  @Groups("!creation")
  id: string;

  @Required()
  prop1: string;

  @RequiredGroups("!patch")
  @Required()
  prop2: string;

  @RequiredGroups("patch")
  @Required()
  prop3: string;
}

AllowedGroups

This feature let your API consumer to define which field he wants to consume. The server will filter automatically fields based on the Groups strategy.

typescript
class MyModel {
  @Property()
  id: string;

  @Property()
  description: string;

  @Groups("summary")
  prop1: string; // not display by default

  @Groups("details")
  prop2: string; // not display by default

  @Groups("admin")
  sensitiveProp: string; // not displayed because it's a sensitive props
}

@Controller("/controllers")
class MyController {
  @Get("/:id")
  @(Returns(200, MyModel).Groups("!admin").AllowedGroups("summary", "details"))
  get() {
    return {
      id: "id",
      description: "description",
      prop1: "prop1",
      prop2: "prop2",
      sensitiveProp: "sensitiveProp"
    };
  }
}

The AllowedGroups is enabled while includes query params is given in the request. Here the different scenario with this parameter:

Request:

          
            GET http://host/rest/controllers/1?includes=summary
          
        

The response will be:

          
          {
            "id": "id",
            "description": "description",
            "prop1": "prop1"
          }
          
        

Request:

        
          GET http://host/rest/controllers/1?includes=summary&includes=details
        
      

OR

        
          GET http://host/rest/controllers/1?includes=summary,details
        
      

Expected JSON:

          
          {
            "id": "id",
            "description": "description",
            "prop1": "prop1",
            "prop2": "prop2"
          }
          
        

Request:

        
          GET http://host/rest/controllers/1
        
      

Expected JSON:

          
          {
            "id": "id",
            "description": "description",
            "prop1": "prop1",
            "prop2": "prop2"
          }
          
        

If a given value isn't listed in the allowed groups, the value will be ignored!

Request:

        
          GET http://host/rest/controllers/1?includes=admin
        
      

Expected JSON:

          
          {
            "id": "id",
            "description": "description",
            "prop1": "prop1",
            "prop2": "prop2"
          }
          
        

Partial

Partial allow you to create a Partial model on an endpoint:

typescript
import {Returns, Patch, Partial} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {BodyParams} from "./bodyParams";

@Controller("/")
class MyController {
  @Patch("/")
  @(Returns(200, MyModel).Groups("group.*"))
  async patch(@BodyParams() @Partial() payload: MyModel) {
    // ...
  }
}

Advanced validation

BeforeDeserialize

If you want to validate or manipulate data before the model has been deserialized you can use the BeforeDeserialize decorator.

Note

Don't forget to return the data in your callback function otherwise an error will occur.

typescript
import {Enum, Property} from "@tsed/schema";
import {BeforeDeserialize} from "@tsed/json-mapper";
import {BadRequest} from "@tsed/exceptions";

enum AnimalType {
  DOG = "DOG",
  CAT = "CAT"
}

@BeforeDeserialize((data: Record<string, unknown>) => {
  if (data.type !== AnimalType.DOG) {
    throw new BadRequest("Sorry, we're only responsible for dogs");
  } else {
    data.name = `Our dog ${data.name}`;
    return data;
  }
})
export class Animal {
  @Property()
  name: string;
  @Enum(AnimalType)
  type: AnimalType;
}

AfterDeserialize

If you want to validate or manipulate data after the model has been deserialized you can use the AfterDeserialize decorator.

Note

Don't forget to return the data in your callback function otherwise an error will occur.

typescript
import {Enum, Property} from "@tsed/schema";
import {AfterDeserialize} from "@tsed/json-mapper";
import {BadRequest} from "@tsed/exceptions";

enum AnimalType {
  DOG = "DOG",
  CAT = "CAT"
}

@AfterDeserialize((data: Animal) => {
  if (data.type !== AnimalType.CAT) {
    throw new BadRequest("Sorry, we're only responsible for cats");
  } else {
    data.name = `Our cat ${data.name}`;
    return data;
  }
})
export class Animal {
  @Property()
  name: string;
  @Enum(AnimalType)
  type: AnimalType;
}

Custom validation decorator

Validation can quickly become complex and therefore confusing. In this case you can use your own validation decorator.

typescript
import {BeforeDeserialize} from "@tsed/json-mapper";
import {Property, JsonEntityFn} from "@tsed/schema";
import {BadRequest} from "@tsed/exceptions";

class Company {
  @Property()
  name: string;
  @Property()
  @RequiredIf((value: any, data: any) => data.name === "tsed" && value !== undefined)
  location: string;
}

function RequiredIf(cb: any): PropertyDecorator {
  return JsonEntityFn((store, [target, propertyKey]) => {
    BeforeDeserialize((data) => {
      if (!cb(data[propertyKey], data)) {
        throw new BadRequest(`${String(propertyKey)} is required`);
      }
      return data;
    })(target);
  });
}

Discriminator v7.8.0+

The discriminator feature allows polymorphism with JsonSchema and OpenAPI. Although OneOf already allows polymorphism in terms of validation, the latter doesn't allow the @tsed/json-mapper to render the correct class type during the deserialization (plain object to class).

By declaring a discriminatorKey, @tsed/json-mapper will be able to determine the correct class which should be used.

Here is an example:

typescript
import {DiscriminatorKey, DiscriminatorValue, OneOf, Property, Required} from "@tsed/schema";

export enum EventType {
  PAGE_VIEW = "page_view",
  ACTION = "action",
  CLICK_ACTION = "click_action"
}

export class Event {
  @DiscriminatorKey() // declare this property as discriminator key
  type: string; // Note: Do not set EventType enum here. The @DiscriminatorKey decorator will automatically generate the correct values based on @DiscriminatorValue decorators in derived classes.

  @Property()
  value: string;
}

@DiscriminatorValue(EventType.PAGE_VIEW)
// or @DiscriminatorValue() value can be inferred by the class name (ex: "page_view")
export class PageView extends Event {
  override type = EventType.PAGE_VIEW; // optional

  @Required()
  url: string;
}

@DiscriminatorValue(EventType.ACTION, EventType.CLICK_ACTION)
export class Action extends Event {
  @Required()
  event: string;
}

export class Tracking {
  @OneOf(Action, PageView)
  data: Action | PageView;
}

And now we can use deserialize to map plain object to a class:

typescript
import {deserialize} from "@tsed/json-mapper";
import {Tracking} from "./Tracking";

const list = {
  data: [
    {
      type: "page_view",
      value: "value",
      url: "https://url"
    },
    {
      type: "action",
      value: "value",
      event: "event"
    },
    {
      type: "click_action",
      value: "value",
      event: "event"
    }
  ]
};

const result = deserialize(list, {
  type: Tracking
});

expect(result.data[0]).toBeInstanceOf(PageView);
expect(result.data[1]).toBeInstanceOf(Action);
expect(result.data[2]).toBeInstanceOf(Action);
expect(result.data[3]).toBeInstanceOf(CustomAction);

Shortcut

Declaring each time the list of children class using OneOf decorator can be a pain point, so Ts.ED provide a way to simplify your code:

Instead of declaring all classes:

ts
export class Tracking {
  @OneOf(Action, PageView)
  data: Action | PageView;
}

Give the parent class to OneOf decorator:

typescript
export type EventsType = Action | PageView;

export class Tracking {
  @OneOf(Event)
  data: EventsType;
}

Ts.ED will automatically infer the children classes!

Discriminator model can be used also on controller:

typescript
@Controller("/")
class Test {
  @Put("/:id")
  @(Returns(200).OneOf(Event))
  put(@PathParams(":id") id: string, @BodyParams() @OneOf(Event) event: EventsType) {
    return [];
  }
}

Generics

Declaring a generic model

Sometimes, it might be useful to use generic models. TypeScript doesn't store the generic type in the metadata. This is why we need to declare explicitly the generic models with the decorators.

One of the generic's usage can be a paginated list. With Returns decorator, it's now possible to declare generic type and generate the appropriate OpenSpec documentation.

Starting with the pagination model, by using Generics and CollectionOf:

ts
import {CollectionOf, Generics, Property} from "@tsed/schema";

@Generics("T")
class Pagination<T> {
  @CollectionOf("T")
  data: T[];

  @Property()
  totalCount: number;
}

Now, we need a model to be used with the generic Pagination model:

ts
import {Property} from "@tsed/schema";

class Product {
  @Property()
  id: string;

  @Property()
  title: string;
}

Finally, we can use our models on a method as following:

ts
import {Post, Returns} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {Pagination} from "../models/Pagination";
import {Product} from "../models/Product";

@Controller("/")
class MyController {
  @Post("/")
  @Returns(200, Pagination).Of(Product).Description("description")
  method(): Promise<Pagination<Product> | null> {
    return Promise.resolve(null);
  }
}
json
{
  "definitions": {
    "Product": {
      "properties": {
        "title": {
          "type": "string"
        }
      },
      "type": "object"
    },
    "Submission": {
      "properties": {
        "_id": {
          "type": "string"
        },
        "data": {
          "$ref": "#/definitions/Product"
        }
      },
      "type": "object"
    }
  },
  "tags": [
    {
      "name": "MyController"
    }
  ],
  "paths": {
    "/": {
      "post": {
        "operationId": "myControllerMethod",
        "tags": ["MyController"],
        "parameters": [
          {
            "in": "body",
            "name": "body",
            "required": false,
            "schema": {
              "$ref": "#/definitions/Submission"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success"
          }
        }
      }
    }
  }
}
json
{
  "components": {
    "schemas": {
      "Product": {
        "properties": {
          "title": {
            "type": "string"
          }
        },
        "type": "object"
      },
      "Submission": {
        "properties": {
          "_id": {
            "type": "string"
          },
          "data": {
            "$ref": "#/components/schemas/Product"
          }
        },
        "type": "object"
      }
    }
  },
  "paths": {
    "/": {
      "post": {
        "operationId": "myControllerMethod",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Submission"
              }
            }
          },
          "required": false
        },
        "responses": {
          "200": {
            "description": "Success"
          }
        },
        "tags": ["MyController"]
      }
    }
  },
  "tags": [
    {
      "name": "MyController"
    }
  ]
}

Declaring nested generic models

It's also possible to declare nested generic models in order to have this type Pagination<Submission<Product>>:

typescript
import {Post, Generics, Property, Returns} from "@tsed/schema";

class MyController {
  @Post("/")
  @(Returns(200, Pagination).Of(Submission).Nested(Product).Description("description"))
  async method(): Promise<Pagination<Submission<Product>> | null> {
    return null;
  }
}
ts
import {Generics, Property} from "@tsed/schema";

@Generics("T")
class Submission<T> {
  @Property()
  _id: string;

  @Property("T")
  data: T;
}
ts
import {CollectionOf, Generics, Property} from "@tsed/schema";

@Generics("T")
class Pagination<T> {
  @CollectionOf("T")
  data: T[];

  @Property()
  totalCount: number;
}
ts
import {Property} from "@tsed/schema";

class Product {
  @Property()
  id: string;

  @Property()
  title: string;
}
json
{
  "definitions": {
    "Product": {
      "properties": {
        "title": {
          "type": "string"
        }
      },
      "type": "object"
    }
  },
  "paths": {
    "/": {
      "post": {
        "operationId": "myControllerMethod",
        "parameters": [],
        "produces": ["application/json"],
        "responses": {
          "200": {
            "description": "description",
            "schema": {
              "properties": {
                "data": {
                  "items": {
                    "properties": {
                      "_id": {
                        "type": "string"
                      },
                      "data": {
                        "$ref": "#/definitions/Product"
                      }
                    },
                    "type": "object"
                  },
                  "type": "array"
                },
                "totalCount": {
                  "type": "number"
                }
              },
              "type": "object"
            }
          }
        },
        "tags": ["MyController"]
      }
    }
  },
  "tags": [
    {
      "name": "MyController"
    }
  ]
}
json
{
  "components": {
    "schemas": {
      "Product": {
        "properties": {
          "title": {
            "type": "string"
          }
        },
        "type": "object"
      }
    }
  },
  "paths": {
    "/": {
      "post": {
        "operationId": "myControllerMethod",
        "parameters": [],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "data": {
                      "items": {
                        "properties": {
                          "_id": {
                            "type": "string"
                          },
                          "data": {
                            "$ref": "#/components/schemas/Product"
                          }
                        },
                        "type": "object"
                      },
                      "type": "array"
                    },
                    "totalCount": {
                      "type": "number"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "description"
          }
        },
        "tags": ["MyController"]
      }
    }
  },
  "tags": [
    {
      "name": "MyController"
    }
  ]
}

Generics with Types

ts
import {GenericOf, Generics, getJsonSchema, Property} from "@tsed/schema";

@Generics("T")
class UserProperty<T> {
  @Property("T")
  value: T;
}

class Adjustment {
  @GenericOf(String)
  adjustment: UserProperty<string>;
}

console.log(getJsonSchema(Adjustment));
/* OUTPUT:
{
  "properties": {
    "adjustment": {
      "properties": {
        "value": {
          "type": "string"
        }
      },
      "type": "object"
    }
  },
  "type": "object"
}
*/
ts
import {GenericOf, Generics, getJsonSchema, Property} from "@tsed/schema";

@Generics("T")
class UserProperty<T> {
  @Property("T")
  value: T;
}

class Adjustment {
  @GenericOf(Date)
  adjustment: UserProperty<Date>;
}

console.log(getJsonSchema(Adjustment));
/* OUTPUT:
{
  "properties": {
    "adjustment": {
      "properties": {
        "value": {
          "format": "date-time",
          "type": "string"
        }
      },
      "type": "object"
    }
  },
  "type": "object"
}
*/
ts
import {GenericOf, Generics, getJsonSchema, Property} from "@tsed/schema";

enum AdjustmentType {
  PRICE = "price",
  DELAY = "delay"
}

@Generics("T")
class UserProperty<T> {
  @Property("T")
  value: T;
}

class Adjustment {
  @GenericOf(AdjustmentType)
  adjustment: UserProperty<AdjustmentType>;
}

console.log(getJsonSchema(Adjustment));
/* OUTPUT:
{
  "properties": {
    "adjustment": {
      "properties": {
        "value": {
          "enum": [
            "price"
            "delay"
          ],
          "type": "string"
        }
      },
      "type": "object"
    }
  },
  "type": "object"
}
*/

Generics with Functional API

ts
import {GenericOf, Generics, getJsonSchema, Property, string} from "@tsed/schema";

@Generics("T")
class UserProperty<T> {
  @Property("T")
  value: T;
}

class Adjustment {
  @GenericOf(string().pattern(/[a-z]/))
  adjustment: UserProperty<string>;
}

console.log(getJsonSchema(Adjustment));
/* OUTPUT:
{
  "properties": {
    "adjustment": {
      "properties": {
        "value": {
          "type": "string",
          "pattern": "[a-z]"
        }
      },
      "type": "object"
    }
  },
  "type": "object"
}
*/
ts
import {date, GenericOf, Generics, getJsonSchema, Property, string} from "@tsed/schema";

@Generics("T")
class UserProperty<T> {
  @Property("T")
  value: T;
}

class Adjustment {
  @GenericOf(date().format("date-time"))
  adjustment: UserProperty<Date>;
}

console.log(getJsonSchema(Adjustment));
/* OUTPUT:
{
  "properties": {
    "adjustment": {
      "properties": {
        "value": {
          "type": "string",
          "format": "date-time"
        }
      },
      "type": "object"
    }
  },
  "type": "object"
}
*/

Pagination

The following advanced example will show you how you can combine the different Ts.ED features to describe Pagination. The used features are the following:

ts
import {QueryParams} from "@tsed/platform-params";
import {Get, Returns} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {Pageable} from "../models/Pageable";
import {Pagination} from "../models/Pagination";
import {Product} from "../models/Product";

@Controller("/pageable")
class ProductsCtrl {
  @Get("/")
  @Returns(206, Pagination).Of(Product).Title("PaginatedProduct")
  @Returns(200, Pagination).Of(Product).Title("PaginatedProduct")
  get(@QueryParams() pageableOptions: Pageable, @QueryParams("all") all: boolean) {
    return new Pagination<Product>({
      data: [
        new Product({
          id: "100",
          title: "CANON D3000"
        })
      ],
      totalCount: all ? 1 : 100, // just for test,
      pageable: pageableOptions
    });
  }
}
ts
import {isString} from "@tsed/core";
import {OnDeserialize} from "@tsed/json-mapper";
import {array, Default, Description, For, Integer, Min, oneOf, SpecTypes, string} from "@tsed/schema";

class Pageable {
  @Integer()
  @Min(0)
  @Default(0)
  @Description("Page number.")
  page: number = 0;

  @Integer()
  @Min(1)
  @Default(20)
  @Description("Number of objects per page.")
  size: number = 20;

  @For(SpecTypes.JSON, oneOf(string(), array().items(string()).maxItems(2)))
  @For(SpecTypes.OPENAPI, array().items(string()).maxItems(2))
  @OnDeserialize((value: string | string[]) => (isString(value) ? value.split(",") : value))
  @Description(
    "Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported."
  )
  sort: string | string[];

  constructor(options: Partial<Pageable>) {
    options.page && (this.page = options.page);
    options.size && (this.size = options.size);
    options.sort && (this.sort = options.sort);
  }

  get offset() {
    return this.page ? this.page * this.limit : 0;
  }

  get limit() {
    return this.size;
  }
}
ts
import {CollectionOf, Default, Generics, Integer, MinLength} from "@tsed/schema";
import {Pageable} from "./Pageable";

export class PaginationLink {
  @Property()
  next: string;

  @Property()
  prev: string;
}

@Generics("T")
export class Pagination<T> extends Pageable {
  @CollectionOf("T")
  data: T[];

  @Integer()
  @MinLength(0)
  @Default(0)
  totalCount: number = 0;

  @Property()
  links: PaginationLink = new PaginationLink();

  constructor({data, totalCount, pageable}: Partial<Pagination<T>> & {pageable: Pageable}) {
    super(pageable);
    data && (this.data = data);
    totalCount && (this.totalCount = totalCount);
  }
}
ts
import {Property} from "@tsed/schema";

class Product {
  @Property()
  id: string;

  @Property()
  title: string;

  constructor({id, title}: Partial<Product> = {}) {
    id && (this.id = id);
    title && (this.title = title);
  }
}
ts
import {PlatformContext} from "@tsed/platform-http";
import {ResponseFilter, ResponseFilterMethods} from "@tsed/platform-response-filter";
import {Pagination} from "../models/Pagination";

@ResponseFilter("application/json")
class PaginationFilter implements ResponseFilterMethods {
  transform(data: unknown, ctx: PlatformContext): any {
    if (ctx.data instanceof Pagination) {
      // /!\ don't modify the ctx.data. at this step, the serializer has already been called.

      if (ctx.data.totalCount > (ctx.data.pageable.page + 1) * ctx.data.pageable.size) {
        ctx.response.status(206);
        data.links.next = `${ctx.request.url}?page=${ctx.data.pageable.page + 1}&size=${ctx.data.pageable.size}`;
      }

      if (ctx.data.pageable.page > 0) {
        data.links.prev = `${ctx.request.url}?page=${ctx.data.pageable.page - 1}&size=${ctx.data.pageable.size}`;
      }
    }

    return data;
  }
}
ts
import {Property} from "@tsed/schema";

class Product {
  @Property()
  id: string;

  @Property()
  title: string;

  constructor({id, title}: Partial<Product> = {}) {
    id && (this.id = id);
    title && (this.title = title);
  }
}

Deep object on query

With OpenAPI 3, it's possible to describe and use a deepObject style as Query params. It means, a consumer can call your endpoint with the following url:

/users?id[role]=admin&id[firstName]=Alex

Ts.ED will determine automatically the appropriate style parameter based on the given User model. Here is an example with a DeepQueryObject model:

typescript
class DeepQueryObject {
  @Property()
  path: string;

  @Property()
  condition: string;

  @Property()
  value: string;
}

@Path("/test")
class TestDeepObjectCtrl {
  @OperationPath("GET", "/")
  async get(@QueryParams("s") q: DeepQueryObject) {}
}

The url to be called will be:

/test?s[path]=title&s[condition]=eq&s[value]=tsed

And the generated swagger will be:

json
{
  "components": {
    "schemas": {
      "DeepQueryObject": {
        "properties": {
          "condition": {
            "type": "string"
          },
          "path": {
            "type": "string"
          },
          "value": {
            "type": "string"
          }
        },
        "type": "object"
      }
    }
  },
  "paths": {
    "/test": {
      "get": {
        "operationId": "testDeepObjectCtrlGet",
        "parameters": [
          {
            "in": "query",
            "name": "s",
            "required": false,
            "style": "deepObject",
            "schema": {
              "$ref": "#/components/schemas/DeepQueryObject"
            }
          }
        ]
      }
    }
  }
}

TIP

Ts.ED support also Generics Deep object style!

typescript
class FindQuery {
  @Property()
  tableColumnNameA?: number;

  @Property()
  tableColumnNameB?: number;
}

@Generics("T")
class PaginationQuery<T> {
  @Minimum(0)
  @Default(0)
  offset?: number;

  @Minimum(1)
  @Maximum(1000)
  @Default(50)
  limit?: number;

  @Property("T")
  where?: T;
}

@Path("/test")
class TestDeepObjectCtrl {
  @OperationPath("GET", "/")
  async get(@QueryParams() @GenericOf(FindQuery) q: PaginationQuery<FindQuery>) {}
}

WARNING

This feature is only available for OpenAPI 3.

Annotations

JSON Schema includes a few keywords and Ts.ED provide also theses corresponding decorators like Title, Description, Default, Example that aren’t strictly used for validation, but are used to describe parts of a schema.

None of these annotation keywords are required, but they are encouraged for good practice, and can make your schema self-documenting.

ts
import {Default, Description, Example, Title} from "@tsed/schema";

export class Model {
  @Title("title")
  @Example("example")
  @Description("Description")
  @Default("default")
  prop: string = "default";
}
json
{
  "definitions": {},
  "properties": {
    "prop": {
      "default": "default",
      "description": "Description",
      "examples": ["example"],
      "title": "title",
      "type": "string"
    }
  },
  "type": "object"
}

Alias

Name decorator lets you rename the exposed property in your json schema.

For example mongo db uses the _id property. In order not to give any indication to our consumer about the nature of the database, it's better to rename the property to id.

ts
import {Description, Example, Name} from "@tsed/schema";
import {ObjectID} from "@tsed/mongoose";

export class Model {
  @Name("id")
  @Description("Object ID")
  @Example("5ce7ad3028890bd71749d477")
  _id: string;
}

// same example with mongoose
export class Model2 {
  @ObjectID("id")
  _id: string;
}

Set Schema

If Ts.ED doesn't provide the expected decorator to describe your json schema, you can use the Schema decorator from @tsed/schema to set a custom schema.

Using JsonSchemaObject

You can declare schema by using the JsonSchemaObject interface:

ts
import {BodyParams} from "@tsed/platform-params";
import {JsonSchemaObject, Post, Returns, Schema} from "@tsed/schema";
import {Controller} from "@tsed/di";

const ProductSchema: JsonSchemaObject = {
  type: "object",
  properties: {}
};

export class MyModel {
  @Schema({
    contains: {
      type: "string"
    }
  })
  prop: string;
}

@Controller("/")
class MyController {
  @Post("/")
  @Returns(200).Description("description").Schema(ProductSchema)
  method(@BodyParams() @Schema(ProductSchema) product: any): Promise<null> {
    return Promise.resolve(null);
  }
}

Using functions

It's also possible to write a valid JsonSchema by using the functional approach (Joi like):

ts
import {BodyParams} from "@tsed/platform-params";
import {Post} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {array, number, object, Returns, Schema, string} from "@tsed/schema";

const ProductSchema = object({
  id: string().required().description("Product ID"),
  title: string().required().minLength(3).example("CANON D300").description("Product title"),
  price: number().minimum(0).example(100).description("Product price"),
  description: string().description("Product description"),
  tags: array()
    .minItems(1)
    .items(string().minLength(2).maxLength(10).description("Product tag"))
    .description("Product tags")
}).label("ProductModel");

@Controller("/")
class MyController {
  @Post("/")
  @Returns(200).Description("description").Schema(ProductSchema)
  method(@BodyParams() @Schema(ProductSchema) product: any): Promise<null> {
    return Promise.resolve(null);
  }
}

Here is the list of available functions:

Loading in progress...

RecordOf

The RecordOf decorator constructs a json schema object type which property keys are set by a given set of keys and which property values are of a given type.

ts
import {RecordOf} from "@tsed/schema";

type keys = "tech" | "hr";

class Department {
  employeeSize: number;
}

type Departments = Record<keys, Department>;

class Company {
  @RecordOf(Department, "tech", "hr")
  departments: Departments;
}
json
{
  "definitions": {
    "Department": {
      "type": "object"
    }
  },
  "type": "object",
  "properties": {
    "Departments": {
      "properties": {
        "tech": {
          "$ref": "#/definitions/Department"
        },
        "hr": {
          "$ref": "#/definitions/Department"
        }
      },
      "type": "object"
    }
  }
}

Get Json schema

In some cases, it may be useful to retrieve the JSON Schema from a Model to use with another library. This is possible by using getJsonSchema. Here is a small example:

ts
import {getJsonSchema, MinLength, Required} from "@tsed/schema";

class PersonModel {
  @MinLength(3)
  @Required()
  firstName: string;

  @MinLength(3)
  @Required()
  lastName: string;
}

const schema = getJsonSchema(PersonModel);

console.log(schema);
json
{
  "definitions": {},
  "properties": {
    "firstName": {
      "minLength": 3,
      "type": "string"
    },
    "lastName": {
      "minLength": 3,
      "type": "string"
    }
  },
  "required": ["firstName", "lastName"],
  "type": "object"
}

Expose a JsonSchema

You can create a controller, or an endpoint to expose a specific schema with the custom keys. This can allow your consumers to retrieve a validation template so that they can use it to validate a form.

typescript
import {Controller} from "@tsed/di";
import {Get, getJsonSchema} from "@tsed/schema";
import {Product} from "../models/Product";

@Controller("/products")
export class ProductsCtrl {
  @Get("/.schema")
  get(@QueryParams("customKeys") customKeys: boolean, @QueryParams("groups") groups: string[]) {
    return getJsonSchema(Product, {customKeys, groups});
  }
}

Get OpenSpec

In some cases, it may be useful to retrieve the OpenSpec from a Controller to generate the Swagger OpenSpec. This is possible by using getSpec. Here is a small example:

ts
import {getSpec, Post, Returns, SpecTypes} from "@tsed/schema";
import {Controller} from "@tsed/di";
import {Pagination} from "../models/Pagination";
import {Product} from "../models/Product";

@Controller("/")
class MyController {
  @Post("/")
  @Returns(200, Pagination).Of(Product).Description("description")
  method(): Promise<Pagination<Product> | null> {
    return Promise.resolve(null);
  }
}

const spec = getSpec(MyController, {specType: SpecTypes.OPENAPI});

console.log(spec);
json
{
  "components": {
    "schemas": {
      "Product": {
        "properties": {
          "title": {
            "type": "string"
          }
        },
        "type": "object"
      },
      "Submission": {
        "properties": {
          "_id": {
            "type": "string"
          },
          "data": {
            "$ref": "#/components/schemas/Product"
          }
        },
        "type": "object"
      }
    }
  },
  "paths": {
    "/": {
      "post": {
        "operationId": "myControllerMethod",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Submission"
              }
            }
          },
          "required": false
        },
        "responses": {
          "200": {
            "description": "Success"
          }
        },
        "tags": ["MyController"]
      }
    }
  },
  "tags": [
    {
      "name": "MyController"
    }
  ]
}

Decorators

Loading in progress...

Released under the MIT License.