Skip to main content

Shared Type System

Overview

The Wrkbelt platform employs a unified type system that serves as the foundation for both frontend and backend, enabling type-safe development across the entire stack. This shared type system ensures consistency, reduces errors, and improves developer productivity by creating a common language for all application components.

Architecture and Design

Type System Organization

The shared type system is organized in the @wrkbelt/shared/types-data library, structured as follows:

libs/shared/types-data/
├── src/
│ ├── index.ts # Main export file
│ └── lib/
│ ├── common/ # Common types across domains
│ │ ├── entities.ts # Base entity definitions
│ │ ├── response.ts # Standardized response types
│ │ └── ...
│ ├── core/ # Core domain types
│ │ ├── auth/ # Authentication types
│ │ ├── organization/ # Organization types
│ │ │ └── ... (follows same pattern as other domains)
│ │ ├── permission/ # Permission types (EXAMPLE)
│ │ │ ├── permission.entity.ts # Base entity type
│ │ │ ├── permission.enum.ts # Enum values
│ │ │ └── permission.dtos.ts # Base DTO interfaces
│ │ ├── user/ # User types
│ │ │ └── ... (follows same pattern as other domains)
│ │ └── ...
│ ├── integrations/ # External integration types
│ └── marketing/ # Marketing-related types

Note: The permission module is shown in detail as an example. All domain entities follow a similar file structure pattern, with entity types, enums, and DTOs organized consistently.

This organization ensures:

  • Separation of Concerns: Types are grouped by domain and purpose
  • Clear Dependencies: Domain-specific types can import from common, but not across domains
  • Single Source of Truth: Each concept has exactly one type definition

Key Components

1. Base Entity Types

All domain entities extend the BaseEntity interface, providing consistent structure and properties across the system:

/**
* Base entity type that all domain entities must implement.
* Ensures consistent structure across all entities in the system.
*/
export type BaseEntity = {
_id: string;
createdAt: Date;
updatedAt: Date;
};

This base interface ensures that:

  • Every entity has a unique identifier
  • All entities track creation and modification timestamps
  • Type-safety is maintained when working with collections of entities

2. Domain-Specific Entity Types

Domain entities extend the base entity with domain-specific properties. These entity types are defined in the shared library to ensure they can be used across both frontend and backend.

For example, the Permission entity in libs/shared/types-data/src/lib/core/permission/permission.entity.ts:

import { BaseEntity } from '../../common';
import { AllPermissions } from './permission.enum';

export type Permission = {
name: AllPermissions;
description: string;
} & BaseEntity;

3. Enum Types

Enums define fixed sets of values to ensure consistency and type safety. They are also defined in the shared library.

For example, the Permission enums in libs/shared/types-data/src/lib/core/permission/permission.enum.ts:

export enum AllPermissions {
// User Management
VIEW_USERS = 'ViewUsers',
CREATE_USER = 'CreateUser',
UPDATE_USER = 'UpdateUser',
DELETE_USER = 'DeleteUser',

// Permission Management
VIEW_PERMISSIONS = 'ViewPermissions',
CREATE_PERMISSION = 'CreatePermission',
UPDATE_PERMISSION = 'UpdatePermission',
DELETE_PERMISSION = 'DeletePermission',

// Other permissions...
}

export const AllPermissionsMetadata = {
[AllPermissions.CREATE_USER]: {
name: AllPermissions.CREATE_USER,
description: 'Create User',
},
// Other metadata...
} as const;

4. Base DTO Interfaces

Data Transfer Object (DTO) interfaces define the shape of data for API requests and responses. Base DTO interfaces are defined in the shared library to ensure consistency between frontend and backend.

For example, the Permission DTOs in libs/shared/types-data/src/lib/core/permission/permission.dtos.ts:

import { AllPermissions } from './permission.enum';

export interface CreatePermissionDto {
name: AllPermissions;
description: string;
}

export type UpdatePermissionDto = Partial<CreatePermissionDto>;

Implementation in the API

The API extends the shared types to implement backend-specific functionality:

1. Mongoose Schemas

The API defines Mongoose schemas that implement the shared entity types, adding MongoDB-specific decorators and validations.

For example, in libs/api/services-api/src/lib/core/permission/permission.schema.ts:

import {
AllPermissions,
Permission as DomainPermission,
} from '@wrkbelt/shared/types-data';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';

export type PermissionDocument = HydratedDocument<
Permission & DomainPermission
>;

@Schema({ timestamps: true })
export class Permission {
@Prop({
unique: true,
required: true,
type: String,
enum: Object.values(AllPermissions),
})
name!: AllPermissions;

@Prop()
description!: string;
}

export const PermissionSchema = SchemaFactory.createForClass(Permission);

Notice how the PermissionDocument type combines the Mongoose document with the shared domain entity type.

2. API-Specific DTOs

The API extends the base DTO interfaces to add validation decorators and API documentation:

For example, in libs/api/services-api/src/lib/core/permission/dtos/create-permission.dto.ts:

import {
AllPermissions,
AllPermissionsMetadata,
CreatePermissionDto as CreatePermissionDtoBase,
} from '@wrkbelt/shared/types-data';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';

export class CreatePermissionDto implements CreatePermissionDtoBase {
@ApiProperty({
example: AllPermissions.CREATE_MEMBERSHIP,
description: 'The name of the permission',
uniqueItems: true,
})
@IsNotEmpty()
@IsEnum(AllPermissions)
name!: AllPermissions;

@ApiProperty({
example:
AllPermissionsMetadata[AllPermissions.CREATE_MEMBERSHIP].description,
description: 'The description of the permission',
})
@IsNotEmpty()
@IsString()
description!: string;
}

Note how the API's CreatePermissionDto class:

  • Implements the base interface from the shared library
  • Adds validation decorators from class-validator
  • Adds API documentation decorators from @nestjs/swagger
  • Reuses the shared enum values for examples

Similarly, the update DTO is implemented in libs/api/services-api/src/lib/core/permission/dtos/update-permission.dto.ts:

import { UpdatePermissionDto as UpdatePermissionDtoBase } from '@wrkbelt/shared/types-data';

export class UpdatePermissionDto implements UpdatePermissionDtoBase {}

Cross-Stack Type Sharing

Benefits of Shared Types

Using shared types across the full stack provides several advantages:

  1. Type Safety: Compile-time checks catch type mismatches before runtime
  2. DRY (Don't Repeat Yourself): Define types once, use everywhere
  3. Consistency: Ensures UI and API use the same data structures
  4. Developer Experience: Better IDE support with code completion and documentation
  5. Refactoring Support: Changes to types automatically propagate throughout the codebase

Implementation in UI

The UI leverages shared types for type-safe API calls:

// Example: Type-safe API calls in the UI
import {
AllPermissions,
CreatePermissionDto
} from '@wrkbelt/shared/types-data';

// Type-safe request data
const createPermissionData: CreatePermissionDto = {
name: AllPermissions.CREATE_USER,
description: 'Allows creating users',
};

// Type-safe response handling
const createPermission = async (data: CreatePermissionDto) => {
const response = await fetch('/api/permissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});

return response.json();
};

Implementation in API

The API uses the same shared types in controllers and services:

// Example: Controller using shared types
@Post()
@RequirePermissions(AllPermissions.CREATE_PERMISSION)
async createPermission(
@Body() createPermissionDto: CreatePermissionDto
): Promise<Permission> {
return this.permissionService.create(createPermissionDto);
}

// Example: Service using shared types
async create(createPermissionDto: CreatePermissionDto): Promise<Permission> {
const newPermission = new this.permissionModel(createPermissionDto);
return newPermission.save();
}

Type Extensions and Composition

Type Inheritance Pattern

Our system follows a clear inheritance pattern for types:

  1. Base Entity Types (in shared library): Define common properties across all entities
  2. Domain Entity Types (in shared library): Extend base types with domain-specific properties
  3. Schema/Model Types (in API): Implement domain types for the database layer
  4. Base DTO Interfaces (in shared library): Define common input/output structures
  5. Concrete DTO Classes (in API): Implement base interfaces with validations

This pattern ensures type safety and consistency across the entire stack.

Discriminated Unions

For complex type scenarios, discriminated unions provide type safety with runtime type discrimination:

// Example: Notification types using discriminated unions
export type EmailNotification = {
type: 'email';
recipient: string;
subject: string;
body: string;
};

export type SMSNotification = {
type: 'sms';
phoneNumber: string;
message: string;
};

export type PushNotification = {
type: 'push';
deviceId: string;
title: string;
body: string;
data?: Record<string, unknown>;
};

// Combined notification type
export type Notification =
| EmailNotification
| SMSNotification
| PushNotification;

// Type-safe usage
function sendNotification(notification: Notification) {
switch (notification.type) {
case 'email':
// TypeScript knows this is EmailNotification
return sendEmail(notification.recipient, notification.subject, notification.body);
case 'sms':
// TypeScript knows this is SMSNotification
return sendSMS(notification.phoneNumber, notification.message);
case 'push':
// TypeScript knows this is PushNotification
return sendPush(notification.deviceId, notification.title, notification.body, notification.data);
}
}

Best Practices

1. Type Design

  • Keep Types Focused: Each type should represent a single concept
  • Use Descriptive Names: Type names should clearly indicate purpose
  • Avoid Type Duplication: Create shared types for common structures
  • Design for Extension: Use interfaces over types when inheritance might be needed

2. Type Organization

  • Group Related Types: Keep related types in the same file or directory
  • Export Strategically: Only export types that need to be shared
  • Document Type Usage: Add JSDoc comments to explain type purpose and usage
  • Sort by Domain: Organize types by domain area, not by technical characteristics

3. Type Sharing

  • Avoid Circular Dependencies: Structure type imports to prevent circular references
  • Minimize Type Libraries: Keep shared types in a small number of dedicated libraries
  • Version Types Carefully: Consider the impact of type changes on dependent code
  • Test Type Compatibility: Ensure types are compatible between UI and API

Common Pitfalls and Solutions

Pitfall: Type Duplication

Problem: Similar types defined in multiple places, leading to inconsistency.

Solution: Create a single source of truth for each type in the shared library.

Pitfall: Circular Type References

Problem: Types that reference each other create circular dependencies.

Solution: Restructure types to avoid cycles or use type imports rather than value imports.

Pitfall: Excessive Type Complexity

Problem: Overly complex types that are difficult to understand and use.

Solution: Break down complex types into smaller, composable pieces.

Pitfall: Type-Only Changes Breaking Production

Problem: Changes to types alone cause production builds to fail.

Solution: Use type-only imports/exports and ensure type changes are backward compatible.

References