TypeScript cannot sanitize HTML, prevent SQL injection, or authorize requests. Those controls must run at runtime.
It can reduce mistakes inside an application: returning database rows from an API, mixing tenant and user IDs, treating unvalidated JSON as trusted input, or passing vague permission booleans through several layers. The useful pattern is to model trust boundaries explicitly, then keep runtime checks close to the conversion point.
What TypeScript can and cannot do for security
A type system helps when a security property concerns the shape, origin, or permitted flow of values inside an application. It can distinguish a public API response from a database row. It can reject a ProjectId where a UserId is required. It can require a caller to provide a verified access capability before invoking a sensitive operation.
This moves some failures from production behavior to compiler feedback. A refactor that returns passwordHash can fail review because the endpoint must return a public DTO. A tenant-scoped repository method can reject arguments from the wrong domain before tests run.
TypeScript is erased at runtime. An attacker does not send a server a TenantId; they send JSON, headers, cookies, URLs, and request bodies. A cast such as value as TenantId is a developer assertion, not proof that the value is valid.
Keep the responsibility of each control clear:
Control
Problem it addresses
Static types
Accidental misuse of values in application code
Runtime schemas
Malformed or untrusted external data
Authentication and authorization
Whether a caller may perform an action
Parameterized SQL
SQL injection through query data
Output encoding and sanitization
Browser injection risks such as XSS
Tests, monitoring, and review
Regressions outside compiler coverage
Types are one layer. They work when they describe boundaries enforced elsewhere in the system.
Model boundaries instead of passing persistence objects around
A common source of accidental exposure is treating an ORM model or database row as the application’s universal user object. A handler loads a user and returns it. Another handler sends the same object to a logger. Later, a migration adds mfaSecret, passwordHash, a reset token, support notes, or billing metadata.
Every serialization site now needs to remember which fields to remove. This deny-list approach fails when the schema changes faster than callers are audited.
Separate representations based on where data may travel:
Persistence model: Matches stored data and may include sensitive fields.
Domain model: Represents business concepts and operations.
Request DTO: Represents data accepted by a specific endpoint.
Response DTO: Represents data intentionally exposed by an endpoint.
Log event: Contains fields approved for observability.
Not every entity needs each representation. The split has the most value for users, credentials, payments, tenancy, permissions, and objects with internal-only state.
This database row must never be emitted directly at an HTTP boundary:
type UserRow = {
id: string;
email: string;
passwordHash: string;
mfaSecret: string | null;
internalNotes: string | null;
passwordResetToken: string | null;
};
A response DTO states what the endpoint may reveal:
type PublicUserDto = {
id: string;
email: string;
};
function toPublicUser(user: UserRow): PublicUserDto {
return {
id: user.id,
email: user.email,
};
}
The important decision is the allow-list. Adding a column to UserRow does not add it to PublicUserDto. A new sensitive field remains private until someone intentionally exposes it.
async function getCurrentUser(req: Request, res: Response) {
const user = await users.findById(req.auth.userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
return res.json(toPublicUser(user));
}
The mapper is a review point. Requirements to add displayName or an avatar URL appear as a small, visible DTO change.
Apply the same rule to logs
Logging arbitrary objects is another data-exposure boundary. This code is easy to add while debugging and hard to audit later:
logger.info({ user, requestBody: req.body }, "login attempt");
It may record credentials, tokens, personal data, or columns added by a later migration. Prefer event-shaped logs:
type LoginAudit = {
userId: string;
outcome: "success" | "failure";
};
const event = {
userId: user.id,
outcome: "success",
} satisfies LoginAudit;
logger.info(event, "login attempt");
For failures, log a stable error code instead of the raw request body. If support workflows need selected input, create a redaction function and test that sensitive fields never appear.
type LoginFailureAudit = {
emailDomain: string | null;
reason: "invalid_credentials" | "account_locked";
};
function toLoginFailureAudit(
email: string,
reason: LoginFailureAudit["reason"],
): LoginFailureAudit {
const [, domain] = email.split("@");
return {
emailDomain: domain ?? null,
reason,
};
}
Logger redaction settings and ORM serialization hooks are useful backup controls. The log call site should still show the intended data.
Use DTOs and allow-list serialization
Explicit DTOs prevent another problem: endpoint contracts changing because internal models change. ORM relations, joins, virtual fields, and serializer defaults can expose fields the endpoint author did not intend to publish.
Keep response mapping near the API layer. Repository methods should return information needed by the domain layer. Controllers or presentation modules should decide the public contract.
type ProjectRow = {
id: string;
tenantId: string;
name: string;
visibility: "private" | "team";
archivedAt: Date | null;
billingAccountId: string;
};
type ProjectDto = {
id: string;
name: string;
visibility: "private" | "team";
};
function toProjectDto(project: ProjectRow): ProjectDto {
return {
id: project.id,
name: project.name,
visibility: project.visibility,
};
}
This repetition records a policy decision. tenantId, archivedAt, and billingAccountId are not part of this API contract.
For larger APIs, use a presentation layer or serializer module. Avoid generic pick() helpers that scatter unreviewed field lists across the codebase. Named mappers are easier to search, test, and review.
Integration tests should verify real JSON responses, not only TypeScript assignments. Types cannot protect an endpoint that bypasses the mapper or an ORM plugin that serializes a model directly.
it("does not expose credential fields", async () => {
const response = await request(app)
.get("/v1/me")
.set("authorization", userToken);
expect(response.body).toEqual({
id: expect.any(String),
email: "person@example.com",
});
expect(response.body).not.toHaveProperty("passwordHash");
expect(response.body).not.toHaveProperty("mfaSecret");
});
Make identifiers and authority explicit
A string does not state whether it identifies a tenant, user, project, invoice, or external provider account. Plain strings are easy to mix, especially in multi-tenant services where a missing tenant constraint can expose another customer’s data.
Branded types add nominal meaning to TypeScript’s structural type system:
declare const tenantBrand: unique symbol;
declare const userBrand: unique symbol;
declare const projectBrand: unique symbol;
type TenantId = string & { readonly [tenantBrand]: true };
type UserId = string & { readonly [userBrand]: true };
type ProjectId = string & { readonly [projectBrand]: true };
A repository signature can then state its scope:
async function loadUser(tenantId: TenantId, userId: UserId) {
return db.query(
"SELECT id, email FROM users WHERE tenant_id = $1 AND id = $2",
[tenantId, userId],
);
}
Calling loadUser(tenantId, projectId) is a type error. This does not prove the query is correct, but it prevents swapped-argument bugs that can look valid in review.
Construct brands only after validation
Do not export a generic conversion helper that converts any string into any brand. It defeats the purpose. Create domain values after validating data at a boundary.
function asTenantId(value: string): TenantId {
if (!/^[0-9a-f-]{36}$/i.test(value)) {
throw new Error("Invalid tenant ID");
}
return value as TenantId;
}
In production, prefer a UUID parser or the identifier format used by the system instead of maintaining a broad regular expression. One small module should own the cast and validate before casting.
Brands provide no runtime protection. Database constraints, query predicates, row-level security where appropriate, and authorization checks remain necessary.
Model verified authority as a capability
A boolean such as canEdit is easy to pass through several layers, disconnect from its resource, or accidentally derive from untrusted input. A capability type keeps the checked user and resource together.
Use a private brand so modules importing ProjectEditor cannot construct it with a normal object literal.
// project-access.ts
const projectEditorBrand: unique symbol = Symbol("projectEditor");
export type ProjectEditor = {
readonly userId: UserId;
readonly projectId: ProjectId;
readonly [projectEditorBrand]: true;
};
export async function requireProjectEditor(
userId: UserId,
projectId: ProjectId,
): Promise<ProjectEditor> {
const membership = await memberships.find(userId, projectId);
if (!membership || membership.role !== "editor") {
throw new ForbiddenError();
}
return {
userId,
projectId,
[projectEditorBrand]: true,
};
}
export async function renameProject(access: ProjectEditor, name: string) {
return projects.rename(access.projectId, name);
}
The request handler first authenticates the caller, then loads trusted membership data, then obtains the capability:
const access = await requireProjectEditor(req.auth.userId, projectId);
await renameProject(access, input.name);
Keeping the brand private prevents importing modules from constructing this value with an object literal. It does not prevent unsafe casts. Authorization still depends on trusted runtime checks.
Every relevant request must verify identity, tenant membership, ownership, and current policy. A stale role cache, an incorrectly scoped lookup, or a cast can still create an authorization flaw.
Validate data before creating domain values
External values are unknown until checked. This includes more than HTTP request bodies:
URL parameters and query strings
Headers, cookies, and bearer-token claims
Environment variables
Webhook payloads
Queue messages and event streams
Imported CSV files
Third-party API responses
Database data when schemas or migrations are not fully trusted
TypeScript interfaces do not validate any of these values. A request annotated as CreateProjectInput still arrives as bytes over the network.
Use a runtime schema at the boundary, then infer the TypeScript type from that schema. This avoids maintaining two sources of truth.
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().trim().min(1).max(120),
visibility: z.enum(["private", "team"]),
}).strict();
type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
function parseCreateProject(body: unknown): CreateProjectInput {
return CreateProjectSchema.parse(body);
}
.strict() is a policy choice. Rejecting unknown keys can catch client bugs and prevent accidental acceptance of fields such as ownerId or role. Some public APIs intentionally ignore unknown fields for forward compatibility. Choose and document the behavior.
Schema validation checks shape and local constraints. It does not enforce business rules that depend on current state. A valid project name may already be in use. A valid projectId may belong to another tenant. A valid visibility value may be unavailable to the caller’s plan or role.
A safe request flow is usually:
Authenticate the request.
Parse and validate untrusted syntax and shape.
Convert validated primitives into domain values where needed.
Load current resource and membership state.
Authorize the requested operation.
Apply business rules and write through scoped data access.
Set resource limits separately. A schema that allows a 120-character string does not limit the HTTP request body. Configure body-size limits, upload limits, timeouts, queue payload limits, and rate limits at the relevant layers.
Keep SQL parameterized
Types can clarify repository inputs. They do not make string interpolation safe. SQL injection prevention depends on a database driver or query builder that sends data separately from SQL syntax.
Use parameter placeholders for values:
const result = await db.query(
"SELECT id, email FROM users WHERE tenant_id = $1 AND id = $2",
[tenantId, userId],
);
Do not interpolate values into query text, even if the values have branded types or passed a schema:
// Unsafe. The value becomes SQL source text.
await db.query(`SELECT * FROM users WHERE id = '${userId}'`);
A UUID validation rule may reduce risk in one path. It does not make interpolation a safe convention. Validation rules change, other inputs may have weaker constraints, and escaping varies by database and driver.
Dynamic identifiers need an allow-list
Placeholders generally cannot represent SQL identifiers or keywords. If an endpoint supports sorting by selected columns, construct that fragment only from source-owned literals.
const sortColumns = {
createdAt: "created_at",
name: "name",
} as const;
type SortKey = keyof typeof sortColumns;
function parseSortKey(value: unknown): SortKey {
if (value === "createdAt" || value === "name") {
return value;
}
return "createdAt";
}
const sortKey = parseSortKey(req.query.sort);
const sortColumn = sortColumns[sortKey];
const result = await db.query(
`SELECT id, name FROM projects WHERE tenant_id = $1 ORDER BY ${sortColumn} ASC`,
[tenantId],
);
The request controls a known key, not arbitrary SQL. Apply the same approach to sort direction, table selection, and other dynamic SQL fragments.
Treat HTML as a runtime trust boundary
Most rendering should use framework escaping. React escapes strings rendered in JSX by default:
<div>{comment.body}</div>
The risk appears when an application intentionally renders HTML, such as rich-text content or imported documentation. Raw user HTML must not reach dangerouslySetInnerHTML without a reviewed sanitization step.
A brand makes the distinction visible:
declare const sanitizedHtmlBrand: unique symbol;
type SanitizedHtml = string & {
readonly [sanitizedHtmlBrand]: true;
};
The brand alone has no security value:
const unsafe = input as SanitizedHtml;
That cast only changes the compile-time label. The constructor must run a maintained sanitizer with an application-specific policy.
For Node.js or server-side rendering, DOMPurify needs a DOM implementation. The following setup uses JSDOM:
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function sanitizeHtml(input: string): SanitizedHtml {
const clean = DOMPurify.sanitize(input, {
ALLOWED_TAGS: ["p", "a", "strong", "em", "ul", "ol", "li", "code", "pre"],
ALLOWED_ATTR: ["href"],
});
return clean as SanitizedHtml;
}
The rendering component can require sanitized content:
function RichText({ html }: { html: SanitizedHtml }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Keep the sanitizer factory server-only when using SSR. Browser bundles can initialize DOMPurify with the browser window object instead.
Sanitizer settings are security-sensitive code. Review allowed tags, attributes, URL protocols, CSS handling, embedded content, and server-rendering behavior. Keep DOMPurify and JSDOM updated. If the product does not need arbitrary HTML, use Markdown with a restricted renderer or a structured rich-text format.
Adopt the pattern without type theater
Security-focused types lose value when every escape hatch is open. any, broad as casts, non-null assertions, and generic conversion helpers can erase the protections. Some are necessary at isolated interoperability boundaries. They should not be the usual way to satisfy the compiler.
Start at boundaries with direct security consequences:
HTTP responses and logs: Add response DTOs and named log events for user, payment, admin, and tenancy data.
Tenant-scoped access: Introduce distinct identifiers where swapped values or missing scope could expose another customer’s data.
External inputs: Parse request bodies, webhooks, queues, and environment values with runtime schemas.
SQL access: Standardize parameterized database APIs and fixed allow-lists for dynamic identifiers.
Rich-text rendering: Keep raw and sanitized HTML separate, with one narrow sanitization module.
Do not brand every string. A CurrencyCode, TenantId, or SanitizedHtml type justifies its maintenance cost when mixing it with another value can create a real bug. A type used everywhere but protecting nothing adds friction without improving review quality.
Enforce conventions with tooling and tests
Compiler settings help expose unsafe assumptions:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
}
These options are not security controls. They reduce ambiguity around undefined, optional fields, and inheritance, making boundary code easier to reason about.
Use linting and review rules for dangerous escape hatches. Examples include requiring justification for as, prohibiting any outside approved modules, and flagging direct model serialization in controllers. Keep exceptions narrow and documented.
Test what types cannot prove:
An authenticated user cannot read or modify another tenant’s resource.
Sensitive columns do not appear in real HTTP responses or logs.
Webhook verification occurs before payload use.
SQL repository methods retain tenant predicates.
Sanitized rich text rejects unsafe URLs and event handlers.
Role changes and revoked memberships take effect as expected.
Integration tests should exercise real middleware, serializers, and database paths. Mapper unit tests help, but integration tests catch endpoints that bypass the mapper.
Key takeaways
TypeScript improves application security when it represents meaningful boundaries.
Return explicit response DTOs rather than persistence objects. Allow-list fields that may leave the service.
Create dedicated log events and redaction functions. Do not log arbitrary models or request bodies.
Use branded identifiers for values that must not be mixed, especially in tenant-scoped code.
Model verified authority with capability types, but check authorization from trusted state for every relevant request.
Parse external values at runtime before treating them as domain input.
Keep SQL parameterized. Use fixed source-owned allow-lists for dynamic identifiers.
Escape HTML by default. Sanitize only when rendering HTML is required.
Back type design with integration tests, database constraints, dependency maintenance, monitoring, and security review.
Explicit DTOs, scoped identifiers, narrow authority-bearing APIs, and visible trust conversions make unsafe flows easier to spot during implementation and review. They do not replace runtime defenses. They reduce the chance that ordinary application mistakes bypass those defenses.
Top comments (1)
Yes! Parse don't validate, etc., yes yes!
Good stuff!
I used zod with playwright http reqs.
Due to SCRUM backend was constantly changing the schema of the entities without communicating it to frontend propery, so...
Instead of mocking around with contract testing in PactFlow (it just never works, devs bypass it), I started hitting it e2e, and parsing the responses with zod before proceeding.
Worked quite well, you can weave it into the report too, so it looks nifty.
But... that was just grunt work.
What I actually truly enjoyed was Haskell, and the beefed up version Agda.
Oh boi, I like when a programming language can throw punches.
You know... normal languages are too soft.
They bow before us, they mindlessly do what we tell them.
Hehhe, Agda is like... Agda in a sense is like Lilith from Diablo IV 🤣
Now here's a problem, that I face constantly IRL, and I have no answer for it:
You get put into a project.
Business domain object are big (40+ props), and they are nested (at least 6 level of nesting).
So... a simple GET will give you back a response with a json body which is so complex that it is worth a PhD. Simply put: Developers - especially of Java and .NET school of enterprise - simply love huge, overly complex things.
And the funny thing is that... these objects often times cotnain redundant data... but... the redundant data is of course 'buggy'.
Dumbed down, oversimplified example: A 'last modified date' in one prop might be good, but in another part it is stale. And instead of fixing it... people just learn to use the first one, and ignore the second and never yeet it out.
So all in all... I can try to be a boyscout and apply proper reasoning, engineenireng principles, pedanticness, but... IRL just always win, because garbage can be generated so fast nowadays that people can literally write gigs of nonsense in a day.
It is sort of like World War 2 human wave tactics: Eventually enough code can be thrown at quality gates to break them.
P.S.: You seem to be interested around types... try googling 'ghosts of departed proofs'. It is a fun read. It was a functional pearl, I think.