Unde pui un interface sau un type? Răspunsul depinde de un singur lucru: cine îl folosește. Un tip trebuie să stea cât mai aproape de codul care îl folosește și să fie „ridicat” abia când apare un consumator nou, mai departe.
- Folosit de un fișier → în acel fișier.
- Tip al unui input/output → exportat din fișierul componentei.
- Folosit de mai multe fișiere din feature →
features/x/models/. - Folosit de mai multe feature-uri → feature-ul proprietar + API public (sau
shared/modelsdacă e cu adevărat transversal). - Vine de la server → DTO separat + mapper.
Un tip folosit de un singur fișier e un detaliu de implementare. Ținut aproape, e ușor de găsit și de schimbat.
// product-filters.ts
interface FilterChip { // folosit doar aici
label: string;
active: boolean;
}
@Component({ ... })
export class ProductFilters { ... }Inline: când tipul e un detaliu de implementare
// ✓ Tip folosit doar de această componentă → în același fișier
interface SortOption {
value: 'price' | 'rating' | 'name';
label: string;
}
@Component({ selector: 'app-sort-menu', /* ... */ })
export class SortMenu {
protected readonly options: SortOption[] = [
{ value: 'price', label: 'Preț' },
{ value: 'rating', label: 'Rating' },
];
}DTO vs model de domeniu
DTO-ul e contractul cu serverul: snake_case, date ca string, câmpuri null, prețuri în bani. Modelul e ce are nevoie UI-ul. Între ele stă un mapper, aplicat o singură dată, la graniță (în data-access). Componentele nu văd niciodată DTO-uri.
// models/product-dto.ts — EXACT forma de la server
export interface ProductDto {
id: number;
product_name: string;
price_cents: number;
stock: number;
created_at: string; // ISO string
discount: number | null;
}
// models/product.ts — ce are nevoie UI-ul
export interface Product {
id: number;
name: string;
price: number; // lei, nu bani
inStock: boolean; // derivat
createdAt: Date;
discount?: number; // fără null
}
// models/product-mapper.ts — singurul loc care știe de ambele
export function toProduct(dto: ProductDto): Product {
return {
id: dto.id,
name: dto.product_name,
price: dto.price_cents / 100,
inStock: dto.stock > 0,
createdAt: new Date(dto.created_at),
...(dto.discount !== null && { discount: dto.discount }),
};
}{
"id": 1,
"name": "Ana Popescu",
"email": "ana@atlas.dev",
"role": "admin",
"city": "Cluj-Napoca",
"joined_at": "2021-03-14"
}- API-ul are convenții diferite (snake_case, bani în cenți, date ca string).
- API-ul nu e controlat de echipa ta sau se schimbă des.
- UI-ul are nevoie de câmpuri derivate (
inStock,fullName). - Aplicație mare, cu multe ecrane pe același tip.
- CRUD simplu, API-ul (sau un BFF) e făcut exact pentru acest UI.
- Tipurile sunt generate din OpenAPI și deja „curate”.
- Un prototip / panou intern mic — maparea adaugă doar cod.
type sau interface?
// interface: forme de obiecte (extensibile, mesaje de eroare clare)
export interface User {
id: number;
name: string;
}
export interface Admin extends User {
permissions: string[];
}
// type: union-uri, intersecții, tipuri derivate, funcții
export type Role = 'admin' | 'editor' | 'viewer';
export type Id = number | string;
export type UserPreview = Pick<User, 'id' | 'name'>;
export type UserPatch = Partial<Omit<User, 'id'>>;
export type Comparator<T> = (a: T, b: T) => number;
export type LoadState<T> = { status: 'loading' } | { status: 'ok'; data: T } | { status: 'error'; error: string };Enum sau union?
// ✓ Union de string-uri + obiect `as const` în loc de enum
export const ORDER_STATUSES = ['pending', 'paid', 'shipped', 'cancelled'] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number]; // 'pending' | 'paid' | ...
export const ORDER_LABELS: Record<OrderStatus, string> = {
pending: 'În așteptare',
paid: 'Plătită',
shipped: 'Trimisă',
cancelled: 'Anulată',
}; // dacă adaugi un status și uiți eticheta → eroare de compilare
// În template: @for (s of statuses; track s) { ... } — ușor de iterat, fără Object.values(Enum)Denumiri
// ✓ Nume clare, fără prefixe/sufixe inutile
interface Product {} // nu IProduct, nu ProductModel, nu ProductInterface
interface ProductDto {} // forma de la API
interface CreateProductRequest {} // body pentru POST
interface ProductFilters {} // stare de UI
type ProductId = number; // alias semantic (opțional)
// ✗ Interfețe pentru servicii doar „ca să fie”
interface IProductService { list(): Observable<Product[]>; } // nu aduce nimic
// Vrei implementări multiple / mock-uri? → clasă abstractă ca token (vezi „inject() & @Service()”)Derivă, nu rescrie
// Derivă tipuri din sursa de adevăr în loc să le rescrii
type UserRow = Pick<User, 'id' | 'name' | 'email'>;
type ProductForm = Omit<Product, 'id' | 'createdAt'>;
type Store = InstanceType<typeof ProductsStore>;
type CartItem = ReturnType<CartStore['items']>[number];
// Schemă de validare = tip + validare la runtime, o singură sursă
const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.email() });
type User = z.infer<typeof UserSchema>;
// Tipuri generate din OpenAPI (openapi-typescript, orval, ng-openapi-gen)
import type { components } from './generated/api';
type ProductDto = components['schemas']['Product'];Maparea DTO-urilor în modele stă în stratul de API: HttpClient, interceptors & API layer. Uniunile discriminate sunt și baza pentru Mașini de stări cu signals.
Capcane frecvente
- „models/” global cu toate tipurile aplicației — același anti-pattern ca folderele pe tip.
- DTO-uri în componente — o schimbare de API se propagă prin toată aplicația.
anyla graniță (http.get<any>) — pierzi toate beneficiile. Dă tipul DTO-ului.- Tipuri TS ca validare: TypeScript nu verifică răspunsul serverului la runtime. Pentru date critice, validează (Zod / Valibot în
parse). - Interfețe pentru orice (
IUserService) — complexitate fără beneficiu.