Where do you put an interface or a type? The answer depends on one thing: who uses it. A type should stay as close as possible to the code that uses it and be “lifted” only when a new consumer appears further away.
- Used by one file → in that file.
- The type of an input/output → exported from the component’s file.
- Used by several files in a feature →
features/x/models/. - Used by several features → the owning feature + a public API (or
shared/modelsif it is truly cross-cutting). - Comes from the server → a separate DTO + a mapper.
A type used by a single file is an implementation detail. Kept close, it is easy to find and to change.
// product-filters.ts
interface FilterChip { // used only here
label: string;
active: boolean;
}
@Component({ ... })
export class ProductFilters { ... }Inline: when the type is an implementation detail
// ✓ A type used only by this component → in the same file
interface SortOption {
value: 'price' | 'rating' | 'name';
label: string;
}
@Component({ selector: 'app-sort-menu', /* ... */ })
export class SortMenu {
protected readonly options: SortOption[] = [
{ value: 'price', label: 'Price' },
{ value: 'rating', label: 'Rating' },
];
}DTO vs domain model
The DTO is the contract with the server: snake_case, dates as strings, null fields, prices in cents. The model is what the UI needs. Between them sits a mapper, applied once, at the boundary (in data-access). Components never see DTOs.
// models/product-dto.ts — EXACTLY the shape from the 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 — what the UI needs
export interface Product {
id: number;
name: string;
price: number; // currency units, not cents
inStock: boolean; // derived
createdAt: Date;
discount?: number; // no null
}
// models/product-mapper.ts — the only place that knows both
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"
}- The API has different conventions (snake_case, money in cents, dates as strings).
- The API is not controlled by your team or changes often.
- The UI needs derived fields (
inStock,fullName). - A large application with many screens on the same type.
- Simple CRUD, the API (or a BFF) is made exactly for this UI.
- The types are generated from OpenAPI and already “clean”.
- A small prototype / internal panel — the mapping only adds code.
type or interface?
// interface: object shapes (extensible, clear error messages)
export interface User {
id: number;
name: string;
}
export interface Admin extends User {
permissions: string[];
}
// type: unions, intersections, derived types, functions
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 or union?
// ✓ A union of strings + an `as const` object instead of an 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: 'Pending',
paid: 'Paid',
shipped: 'Shipped',
cancelled: 'Cancelled',
}; // if you add a status and forget its label → compile error
// In the template: @for (s of statuses; track s) { ... } — easy to iterate, without Object.values(Enum)Naming
// ✓ Clear names, without useless prefixes/suffixes
interface Product {} // not IProduct, not ProductModel, not ProductInterface
interface ProductDto {} // the shape from the API
interface CreateProductRequest {} // the body for POST
interface ProductFilters {} // UI state
type ProductId = number; // semantic alias (optional)
// ✗ Interfaces for services “just because”
interface IProductService { list(): Observable<Product[]>; } // adds nothing
// Want several implementations / mocks? → an abstract class as the token (see “inject() & @Service()”)Derive, don’t rewrite
// Derive types from the source of truth instead of rewriting them
type UserRow = Pick<User, 'id' | 'name' | 'email'>;
type ProductForm = Omit<Product, 'id' | 'createdAt'>;
type Store = InstanceType<typeof ProductsStore>;
type CartItem = ReturnType<CartStore['items']>[number];
// A validation schema = type + runtime validation, one source
const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.email() });
type User = z.infer<typeof UserSchema>;
// Types generated from OpenAPI (openapi-typescript, orval, ng-openapi-gen)
import type { components } from './generated/api';
type ProductDto = components['schemas']['Product'];Mapping DTOs to models belongs in the API layer: HttpClient, interceptors & the API layer. Discriminated unions are also the basis of State machines with signals.
Common pitfalls
- A global “models/” with all the application’s types — the same anti-pattern as folders by type.
- DTOs in components — an API change spreads through the whole application.
anyat the boundary (http.get<any>) — you lose all the benefits. Give the DTO’s type.- TypeScript types as validation: TypeScript does not check the server’s response at runtime. For critical data, validate (Zod / Valibot in
parse). - Interfaces for everything (
IUserService) — complexity without benefit.