TypeScript

Node.js with TypeScript: Best Practices
Oct 21st 26 - by Devico Team
This guide to Node.js with TypeScript covers the architecture, runtime validation, testing, and observability a production-grade backend actually needs.
Hire
Hire by role
Hire Front-end developers
Hire Back-end developers
Hire Full-stack developers
Hire Android developers
Hire iOS developers
Hire Mobile developers
Hire AI engineers
Hire by skill
Hire JavaScript developers
Hire React Native developers
Hire React.js developers
Hire .NET developers
Hire TypeScript developers
Hire Flutter developers
Hire Golang developers
Hire by country
Devs in Ukraine
Expirienced engineers with strong product focus and fast integration.
Devs in Poland
EU-based developers with reliable delivery and high standards.
Devs in Argentina
Senior engineers with strong technical depth and timezone alignment.

TypeScript
October 19, 2026 - by Devico Team
Summarize with:
TypeScript can reject a bad function call before the code reaches production. That is useful, but it is not what determines whether a 300,000-line application remains workable after several teams have spent years changing it.
At that scale, the harder question is architectural: can an engineer modify one part of the system without first reconstructing the dependency graph in their head? If a change to billing unexpectedly breaks authentication and reporting, more type annotations will not solve the underlying problem.
The distinction appears in the research. A repository-mining study covering 604 GitHub projects — 299 written in JavaScript and 305 in TypeScript, with more than 16 million lines of code — found that the TypeScript projects had fewer code smells and lower cognitive complexity. They were not, however, significantly less bug-prone. TypeScript made the code easier to understand; it did not make correctness automatic.
For large applications, that is still a substantial advantage. Understandable code is easier to refactor, review, test, and hand over. But the benefit depends on decisions that sit above the language itself: compiler discipline, module boundaries, repository structure, runtime validation, dependency direction, and automated enforcement.
A function signature protects a local contract. Architecture governs the relationships among thousands of those contracts.
This difference becomes visible gradually. One domain imports an internal type from another because exposing it properly would take longer. A service constructs a database client directly because dependency injection feels unnecessary. Two packages duplicate the same API contract to avoid coordination. Each decision is defensible in isolation.
After a few hundred pull requests, those shortcuts become the architecture.
Circular imports appear. No team is certain who owns a shared module. Routine refactoring touches unrelated packages. New engineers spend weeks learning which dependencies are officially supported and which merely happen to work. The compiler still passes, but the cost of making a safe change continues to rise.
Static typing can prevent a meaningful subset of defects. Gao, Bird, and Barr estimated that TypeScript 2.0 could have detected about 15% of the public bugs examined in their study. That figure should not be read as a universal bug-reduction rate: the research used historical JavaScript defects and a specific TypeScript version. It does show that type checking catches real errors. The rest still depends on how the system is structured and what happens at runtime.
Architecture patterns are of limited value if one untyped value can pass through several layers unnoticed. In a large codebase, strict typing is the baseline rather than an optional hardening step.
Set "strict": true in tsconfig.json. It activates a family of checks that includes strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, and useUnknownInCatchVariables.
Two flags do a particularly large share of the work:
strictNullChecks requires the code to account for null and undefined instead of treating them as valid members of every type.
noImplicitAny prevents unresolved types from quietly becoming any and carrying uncertainty through the call graph.
For large applications, two additional flags are usually worth enabling even though they are not part of the strict umbrella:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } }
noUncheckedIndexedAccess changes an array or record lookup from "this value exists" to "this value may be undefined." That is more honest. exactOptionalPropertyTypes distinguishes an absent property from a property explicitly assigned undefined, which matters in patch objects, configuration merging, and API payloads.
TypeScript 6.0, released on March 23, 2026, made strict the default for new projects. It was also the final release built on the previous JavaScript codebase. TypeScript 7.0, released on July 8, 2026, moved the compiler and language service to Go. Microsoft reports that the native implementation is often around ten times faster than TypeScript 6.0. Actual gains depend on the repository and hardware, but the faster compiler makes strict checks in local development and CI considerably less expensive.
A strict module can still receive an unsafe value from a non-strict one. Once any crosses that boundary, the compiler assumes downstream operations are valid. Both files may pass independently even though the contract between them is unreliable.
Large legacy projects should still avoid a single migration that produces thousands of errors at once. That usually ends with broad assertions, suppression comments, or abandoned work.
Instead, establish a separate strict build covering a controlled set of files or packages, then expand its scope. In a monorepo, project references and package-level tsconfig files make this easier. One practical order is:
Enable lower-friction checks such as alwaysStrict and strictBindCallApply.
Remove implicit any values from the selected module.
Address nullability with strictNullChecks.
Add indexed-access and exact-optional-property checks.
Move the next bounded module into the strict configuration.
The error count should remain small enough to fix properly. A migration that relies on mass suppression has changed the configuration without improving the guarantees.
unknown is not a less convenient anyUse unknown when the value is genuinely unknown: parsed JSON, a queue message, a third-party response, or user-controlled input. The compiler then requires validation or narrowing before the value can be used.
any is defensible at a narrow interoperability boundary — for instance, inside a wrapper around an untyped library. It should not escape that wrapper. The important question is not whether a repository contains any annotations, but whether each one is deliberate, isolated, and prevented from propagating.
The most consequential structural decision is where one part of the application stops and another begins.
Domain-Driven Design provides useful language for this, but adopting the entire tactical catalogue is rarely necessary. A frontend module that formats and displays data does not automatically need aggregates, repositories, value objects, and domain events. Those patterns earn their place when the business rules are complex enough to justify them.
The strategic idea is more broadly useful: billing, product catalogue, and identity represent different business responsibilities, so their implementations should not be freely interchangeable or mutually exposed.
A folder name does not create a boundary. It only indicates an intention.
A domain becomes a meaningful boundary when it owns its internal implementation and exposes a small public API. Other domains import from that API, not from arbitrary files several directories deep.
src/ domains/ billing/ internal/ invoice.ts tax-calculator.ts billing.service.ts index.ts catalog/ internal/ index.ts identity/ internal/ index.ts shared/ types.ts
In this structure, billing/index.ts is the domain's front door. It exports the operations and types that consumers are allowed to use. Files under billing/internal/ remain private.
The shared directory deserves restraint. It should contain genuinely cross-cutting contracts and infrastructure, not business logic that nobody could place elsewhere. An unrestricted shared folder often becomes a second, less organized application core.
Suppose the billing module changes its internal invoice representation. Catalog and identity should not need corresponding edits. They should depend on operations such as createInvoice() or getBalance(), not on the object graph billing currently uses to implement them.
That small public surface is a facade. It gives the domain room to evolve without forcing consumers to follow every internal change. It also provides a consistent place for authorization, validation, logging, and domain invariants.
The facade should not re-export the entire module tree. A barrel file that exposes every internal type creates the appearance of encapsulation without any of its benefits.
A monorepo is valuable when multiple applications share code and frequently need coordinated changes. It is not evidence of architectural maturity by itself.
The strongest case for a monorepo is an atomic contract change. A team can update a shared TypeScript type, modify all consumers, and verify the complete dependency graph in one commit. That is difficult when the contract and its consumers live in separately versioned repositories.
A polyrepo is often better when teams release independently, share little code, or deliberately communicate through stable versioned APIs. Combining those projects can introduce coordination and build complexity without removing a real source of friction.
A useful test is simple: how often does a shared change need to land across several applications at once? If that is routine, a monorepo may reduce overhead. If it is unusual, separate repositories probably reflect the organization more accurately.
These tools solve related but different problems. pnpm workspaces provide package and dependency management. Turborepo and Nx add task orchestration, affected builds, and caching.
Setup
Light configuration around existing scripts
More opinionated, with a broader platform
Workspace configuration and package management
Affected work
Filters tasks using the package graph and changed inputs
Uses the project graph for affected-project analysis
Not provided on its own
Caching
Local and remote task caching
Local caching and Nx Cloud; distributed CI is available
Not provided on its own
Boundary enforcement
Requires separate lint or dependency rules
Built-in tag-based module-boundary rule
Not provided
Code generation
No comparable built-in generator system
Generators and executors are first-class features
Not provided
Best fit
Teams that want task caching with limited ceremony
Larger workspaces needing stricter conventions and orchestration
Dependency layer beneath either tool
pnpm Catalogs can centralize dependency version ranges across packages, reducing the drift that occurs when every package.json maintains its own version declarations.
For many TypeScript teams, Turborepo plus pnpm workspaces is a reasonable starting point. It works with existing package scripts and adds caching without imposing a complete project model.
Nx becomes more attractive once the repository needs import-graph-based affected analysis, standardized generators, distributed CI, or built-in boundary enforcement. Adopting that machinery before the corresponding problems exist leaves the team maintaining a platform it barely uses.
Neither tool will rescue an undisciplined package graph. Without import restrictions, a monorepo can become a convenient way for every package to depend on every other package. Organizations that lack experience designing this layer sometimes work with external TypeScript development partners during the initial setup. Establishing boundaries early is substantially easier than separating packages after deep imports have spread across the repository.
The most valuable type patterns are not the cleverest ones. They are the ones that stop developers from constructing values the application cannot safely process.
Request state is often modeled with optional data, an optional error, and a loading flag:
interface RequestState { isLoading: boolean data?: User error?: Error }
This type permits contradictory combinations. A request can be loading while also containing an error. It can report success without data. Nothing in the model defines which fields belong together.
A discriminated union represents each valid state separately:
type RequestState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; error: Error } function assertNever(value: never): never { throw new Error(`Unhandled state: ${JSON.stringify(value)}`) } function render(state: RequestState) { switch (state.status) { case 'idle': return showIdle() case 'loading': return showSpinner() case 'success': return showUser(state.data) case 'error': return showError(state.error) default: return assertNever(state) } }
The status field lets TypeScript narrow the value inside each branch. The assertNever call also makes the switch exhaustive: adding another state without handling it produces a compile-time error.
This pattern works for far more than UI requests. Workflows, payment results, authentication states, job-processing outcomes, and command responses all benefit from making invalid combinations impossible to represent.
TypeScript is structurally typed. If UserId and OrderId are both aliases for string, the compiler treats them as interchangeable.
type UserId = string type OrderId = string
That is convenient until an order ID is passed into a user lookup. Branded types preserve the runtime primitive while making the concepts incompatible at compile time:
type Brand<T, Name extends string> = T & { readonly __brand: Name } type UserId = Brand<string, 'UserId'> type OrderId = Brand<string, 'OrderId'> const asUserId = (value: string): UserId => value as UserId const asOrderId = (value: string): OrderId => value as OrderId function getUser(id: UserId) { // ... } const orderId = asOrderId('ord_123') getUser(orderId) // Error: OrderId is not assignable to UserId
The same technique can distinguish cents from dollars, metres from feet, or validated email addresses from arbitrary strings. Brands add no runtime overhead.
They do not validate the underlying value, however. Constructors such as asUserId() should be kept behind a trusted boundary or combined with runtime validation. Otherwise, a brand becomes another assertion that merely asks the compiler to trust the caller.
Utility types such as Pick, Omit, Partial, Required, and Record reduce contract duplication:
type UserDraft = Partial<User> type PublicUser = Omit<User, 'passwordHash'> type SessionsByUser = Record<UserId, Session>
These definitions follow the canonical User type. If a relevant property changes, the derived types change with it instead of drifting silently.
Derivation still needs judgment. Partial<User> may be appropriate for an internal draft but too permissive for an update API where some fields are immutable. In that case, define the permitted patch explicitly from selected properties:
type UpdateUserInput = Partial<Pick<User, 'displayName' | 'timezone'>>
interface versus typeFor ordinary object shapes, the practical difference is often smaller than team debates suggest.
Use interface when declaration merging is intentional, such as augmenting a third-party library or designing an extensible public API. Use type for unions, conditional types, mapped types, tuples, and other compositions that an interface cannot express directly.
A convention such as "interfaces for object contracts, types for compositions" is reasonable. Consistency is more useful than reopening the argument in every pull request.
Tight coupling tends to produce familiar symptoms:
One central object accumulates unrelated responsibilities because every module can reach it.
High-level services instantiate databases, API clients, and infrastructure adapters directly.
Tests require the full production object graph.
Dependencies are passed through several components or functions that do not use them.
Dependency inversion addresses the underlying relationship. High-level code describes what it needs through an interface, while the concrete implementation is supplied from outside.
interface PaymentGateway { charge(cents: number, token: string): Promise<Receipt> } class CheckoutService { constructor(private readonly payments: PaymentGateway) {} async checkout(cents: number, token: string): Promise<Receipt> { return this.payments.charge(cents, token) } }
CheckoutService does not know whether the gateway uses Stripe, another provider, or a test fake. Changing the implementation does not require changing the checkout logic.
For many systems, constructor injection plus a manual composition root is enough:
const gateway = new StripePaymentGateway(config) const checkout = new CheckoutService(gateway)
A container such as InversifyJS or tsyringe becomes useful when the object graph is large, deeply nested, or dependent on scopes such as one instance per request. Frameworks such as NestJS already organize applications around container-managed providers, so using the existing mechanism is usually sensible.
A container is less attractive when it hides a small graph behind decorators, tokens, and reflection. Adopt one when explicit wiring has become difficult to maintain — not because the project is considered "enterprise."
TypeScript types do not exist at runtime. This matters whenever data enters from a system the compiler cannot inspect:
HTTP request bodies
third-party API responses
queue messages
environment variables
browser storage
database values written by another service
deserialized files
This assertion does not validate anything:
const user = response as User
It suppresses the compiler's uncertainty. If the response is malformed, the incorrect value continues through the application under a trusted type.
The safer pattern is to define a runtime schema, validate external data at the boundary, and infer the TypeScript type from that schema. Zod is a common choice; Zod 4 supports TypeScript 5.5 and later and requires strict mode.
import * as z from 'zod' const UserSchema = z.object({ id: z.uuid(), email: z.email(), role: z.enum(['admin', 'member']), }) type User = z.infer<typeof UserSchema> async function loadUser(response: Response): Promise<User> { const input: unknown = await response.json() return UserSchema.parse(input) }
The value remains unknown until parse() proves that it matches the schema. The static User type and runtime validator also share one definition, so they cannot drift independently.
Validation belongs at trust boundaries, not indiscriminately inside every function. Validate a third-party response when it enters the application, then pass the resulting typed value through internal modules. Rechecking it at every internal call adds runtime work and obscures which boundaries are actually untrusted.
Configuration deserves the same treatment. Validate environment variables during startup so that a missing or malformed value stops deployment immediately, rather than becoming undefined inside an unrelated request hours later.
Some codebases have isolated bad files. Others have structural problems that make otherwise reasonable code difficult to change. The following signals usually indicate the second case.
Module A imports Module B, which eventually imports Module A. The compiler may accept the cycle, but runtime initialization can expose one module to a partially initialized version of the other.
A cycle often means one of two things: the modules belong together, or both depend on a concept that should be extracted into a third module. Breaking the import without reconsidering the responsibility split tends to move the cycle rather than resolve it.
A healthy layered or domain-oriented application has visible dependency direction. UI code may depend on application services; application services may depend on domain contracts; infrastructure implements interfaces defined closer to the domain.
If every part of the repository imports every other part, no module can be tested, released, or refactored in isolation. The blast radius of a change becomes the full application.
An interface with 40 fields often exists because several consumers need different subsets of the same object. Each consumer becomes coupled to properties it never uses, making even a small contract change appear risky.
Split the contract according to actual consumer needs. A service that only needs id and email should not depend on the complete persistence model.
Copying the same type into several packages avoids an immediate dependency, but the copies eventually diverge. One adds an optional field. Another changes an enum. A third still compiles against the old shape.
Shared contracts need one owner and one source of truth. That does not mean placing every model into a global package; only contracts that genuinely cross boundaries should be shared.
any only move upwardAn occasional assertion is not proof of architectural failure. A count that rises continuously is more revealing. It suggests that developers increasingly have to bypass the type system to make changes.
Track occurrences of : any, as, @ts-ignore, and @ts-expect-error over time. The raw count needs context, but sustained growth is an early warning — often visible before incident rates or onboarding time deteriorate.
A boundary that depends entirely on reviewer memory will eventually fail. The violating import may look harmless, and the reviewer may not know that a previous decision prohibited it.
Architecture needs mechanical enforcement.
dependency-cruiser scans imports and validates the resulting graph against project-specific rules. It can identify circular dependencies, orphaned modules, forbidden cross-layer imports, and package relationships that violate the intended structure. It can also generate graphs for investigation.
eslint-plugin-boundaries brings similar rules into ESLint. Files are classified as architectural elements, and the configuration defines which elements may depend on which others. Developers see violations in the editor rather than after the architecture has already spread through several pull requests.
Nx users can apply its built-in module-boundary rule, including tag-based constraints between projects. Adding another boundary tool may be unnecessary if Nx already models the required relationships.
Whichever option the team chooses, run it in CI and fail the build on new violations. During a legacy migration, existing violations can be baselined temporarily, but the baseline should not permit the count to grow.
Tools preserve the rule. They do not preserve the reason.
An architecture decision record should capture:
the context that required a decision;
the chosen approach;
the alternatives considered;
the expected consequences;
the date and current status.
For a TypeScript application, an ADR might explain why IDs are branded, why validation happens at selected boundaries, or why the organization chose Turborepo instead of Nx. That context prevents a future engineer from "simplifying" the architecture by reversing a decision whose original constraints are no longer visible.
ADRs work because they are short and live beside the code. If producing one requires a formal approval process and a long document, teams will stop writing them.
Prettier and ESLint should settle formatting. Reviewers can then spend attention on questions that require judgment:
Does the change cross a domain boundary?
Has it introduced a dependency in the wrong direction?
Is an existing contract being duplicated?
Is a type assertion hiding unvalidated input?
Has the public API of a module grown unnecessarily?
Does the decision need an ADR?
A short architecture checklist in the pull-request template is more useful than asking reviewers to remember every convention.
Strong typing cannot compensate for an uncontrolled dependency graph. Well-separated modules are still vulnerable if external data is asserted rather than validated. Assess the dimensions together, then prioritize the weakest one.
Rate each area from 1 to 5.
Type strictness
strict is disabled or inconsistently applied; any and assertions continue to spread
strict and additional safety flags are enabled; escape hatches are confined to controlled edges
Module boundaries
Deep cross-domain imports are common; modules have no defined public API
Domains expose small entry points, and consumers cannot import internal files
Monorepo hygiene
Builds run indiscriminately; package boundaries are informal
Affected builds and caching are configured; package relationships are enforced
Runtime validation
External data is accepted through assertions
Schemas validate untrusted data at each system boundary, with types inferred from the schemas
Governance
Architecture relies on individual reviewers; decisions lose their context
Boundary rules run in CI, important decisions have ADRs, and reviews examine structural impact
The total score is less informative than its shape. A repository scoring well everywhere except runtime validation has a focused problem. A flat set of low scores usually indicates that the application grew faster than its structure.
In that situation, start with strict typing and boundary enforcement. Those controls make later refactoring safer because they expose unsafe assumptions and prevent the dependency graph from deteriorating further during the work.
Architecture work can stall even when the team understands the problem. The missing resource may be senior capacity rather than knowledge.
Common signals include:
onboarding remains slow despite repeated documentation efforts;
important architecture decisions stay unresolved for weeks;
the monorepo is growing, but nobody has designed or governed one before;
structural work is repeatedly displaced by feature commitments;
no senior engineer owns the TypeScript architecture end to end.
Adding another mid-level developer is unlikely to resolve those conditions. The team needs someone who can make trade-offs, establish enforceable boundaries, and transfer the decisions to the people who will maintain the system.
Growing that capability internally is usually the best long-term option, but it takes time. Hiring a full-time architect offers continuity, though the search may last months and the role can be difficult to justify once a time-boxed restructuring is complete. A contract architect is better suited to a defined intervention: assess the codebase, establish the architecture, implement enforcement, and hand ownership back to the team.
A practical 30/60/90-day engagement might look like this:
During the first 30 days, assess strictness, dependency structure, runtime boundaries, repository tooling, and governance. The output should be a prioritized plan, not a generic architecture document.
By day 60, implement the highest-leverage changes: a strict-mode migration path, domain or package boundaries, CI enforcement, and the first ADRs.
By day 90, shift the emphasis to pairing, documentation, and ownership transfer so the internal team can continue without the architect.
Companies that cannot wait for a full-time search may augment the team with dedicated TypeScript developers who can lead the structural work while mentoring existing engineers.
Vetting should focus on decisions rather than terminology. Ask candidates where they placed bounded contexts in a real system, why they rejected a monorepo in a particular case, or how they introduced strict mode without flooding the team with errors. Strong architects explain the conditions under which a pattern works and the trade-offs it introduces. Someone who presents every pattern as universally correct is unlikely to handle a large codebase well.
TypeScript architecture is not a restructuring exercise that ends once the folders look cleaner. It is the ongoing control of dependencies, contracts, runtime trust, and decision-making. The best next step is usually visible: find the weakest dimension in the scorecard and make that weakness harder to reproduce.
Usually, yes — but not because it guarantees fewer production bugs.
Research indicates that TypeScript projects can be easier to understand and contain fewer code smells than comparable JavaScript projects. At enterprise scale, safer refactoring, better navigation, and clearer contracts are substantial benefits. They depend on strict configuration and sound architecture; a codebase dominated by any, assertions, and unrestricted imports will capture much less value.
interface and type in large projects?For ordinary object shapes, either can work. interface supports declaration merging, making it useful for third-party augmentation and intentionally extensible APIs. type can express unions, intersections, conditional types, mapped types, and tuples.
Choose a clear convention and automate it where possible. Consistency matters more than treating one form as universally superior.
Use one when several applications share code that changes together. A monorepo lets a team update a shared contract and all its consumers atomically, with one CI run checking the complete change.
If teams have independent release cycles and little shared code, separate repositories usually provide cleaner autonomy with less tooling overhead.
tsconfig be for an enterprise application?Use "strict": true, then consider noUncheckedIndexedAccess and exactOptionalPropertyTypes. TypeScript 6.0 made strict mode the default for new projects, but existing configurations still need review.
Migrate legacy systems incrementally through package-level or scoped strict builds. Avoid enabling everything in one step if the result will be thousands of errors that developers suppress rather than fix.
No. TypeScript types are erased during compilation. They cannot verify an HTTP response, request body, environment variable, queue message, or stored value at runtime.
Treat external data as unknown, validate it with a runtime schema, and infer the static type from that schema. Once the value has passed the boundary, internal modules can rely on the resulting TypeScript type.
TypeScript

Oct 21st 26 - by Devico Team
This guide to Node.js with TypeScript covers the architecture, runtime validation, testing, and observability a production-grade backend actually needs.
Staff augmentation

Oct 20th 26 - by Devico Team
Fixing your tech recruiting process one bottleneck at a time cuts time-to-hire and stops strong engineering candidates from accepting a rival's offer.
TypeScript

Oct 16th 26 - by Devico Team
Learn how to migrate complex JavaScript projects to TypeScript smoothly. Step-by-step strategies for type definitions, refactoring, and risk reduction.