TypeScript and strict mode
TypeScript and strict mode
TypeScript is a language that adds static types to JavaScript. This post lays out TypeScript's origins, how it works, and what strict: true actually turns on, all on a factual basis. We also touch on the gap left by runtime validation and where similar tools sit.
1. About TypeScript
The language was led by Microsoft's Anders Hejlsberg (known as the designer of Turbo Pascal, Delphi, and C#). The first public release came in October 2012, and 1.0 followed in April 2014. The repository is microsoft/TypeScript, licensed under Apache-2.0.
TypeScript is designed as a superset of JavaScript. Every valid JS is valid TS, and the compiler emits JS with type information stripped. In other words, TS types exist only at compile time and leave almost no trace at runtime (exceptions: artifacts like enum and namespace).
There are two distributables — the compiler tsc and the language server tsserver. The latter powers autocomplete and error reporting in VS Code, WebStorm, and others.
2. Structural typing
TypeScript's type system is structural typing. Unlike Java or C#'s nominal typing, two types are compatible based on shape, not name:
interface Point { x: number; y: number }
function len(p: Point) { return Math.hypot(p.x, p.y) }
const p = { x: 3, y: 4, label: "a" } // not declared as Point
len(p) // passes if the shape matches
3. What strict: true turns on
Setting "strict": true in tsconfig.json flips the following bundle of options at once (from the official Strict Mode Family doc):
| Option | Meaning |
|---|---|
noImplicitAny |
Treats inference failures that would default to implicit any as errors. |
strictNullChecks |
null and undefined are no longer automatically included in every type. Only intentional places get T | null. |
strictFunctionTypes |
Enforces strict contravariant checking on function parameters. |
strictBindCallApply |
Type-checks arguments to bind, call, and apply. |
strictPropertyInitialization |
Forces class fields to be initialized in the constructor (requires strictNullChecks). |
alwaysStrict |
Auto-prepends "use strict" to emitted JS files and analyzes in strict mode. |
useUnknownInCatchVariables |
The default type of e in try { } catch (e) is unknown instead of any. |
Among these, strictNullChecks alone is often cited as delivering the most value. It moves null/undefined runtime errors to compile time.
4. Generics, conditional, and mapped types
// generic
function first<T>(arr: T[]): T | undefined { return arr[0] }
// mapped type
type Partial<T> = { [K in keyof T]?: T[K] }
// conditional type
type NonNull<T> = T extends null | undefined ? never : T
When combined with infer, keyof, and template literal types, you end up with a small functional language operating at the type level.
5. Other paths
| Tool | Position | Note |
|---|---|---|
| TypeScript | Microsoft, 2012 | De facto standard. tsc handles checking + transpiling. |
| Flow | Meta, 2014 | A similar effort. Now mostly internal to Meta. Combined with the Hermes engine. |
JSDoc + // @ts-check |
1995 (JSDoc) | Types in plain JS files without a build tool. tsc can read it. |
| esbuild, swc, Babel | Separate | Skip type checking. They only strip type information (fast). Checking goes through tsc --noEmit separately. |
esbuild and swc deliberately do not check types — they erase them. In large codebases, it is common to split build and check: build with the fast tools, run tsc --noEmit separately for verification.
6. tsconfig starting point
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"esModuleInterop": true
}
}
noUncheckedIndexedAccess and exactOptionalPropertyTypes are not part of the strict bundle, but turning them on separately is common.
7. Practical type patterns
Beyond syntax, the choices you make every day. Not big features but small decisions that shape the grain of a codebase.
interface vs type — both can describe an object shape, but each is good at different things. Use interface for object shapes, extension, and declaration merging; use type for unions, intersections, conditional types, and primitive aliases:
// interface — object shapes, extend with extends, same-name redeclarations merge
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
// type — only type can express unions, intersections, and aliases
type Id = string | number
type Result = { ok: true; value: number } | { ok: false; error: string }
For a single object either works, so follow the team rule. The moment you need a union, it naturally branches to type.
Utility types — derive a new type from an existing one. Instead of re-writing a partial-field interface by hand, reuse the standard tools:
interface User { id: string; name: string; email: string; createdAt: Date }
type UserCard = Pick<User, "id" | "name"> // only some fields
type PublicUser = Omit<User, "email"> // exclude some fields
type UserDraft = Partial<User> // all fields optional
type UserInput = Required<UserDraft> // all fields required
type UsersById = Record<string, User> // a key-value map
type Make = ReturnType<typeof createUser> // extract a function's return type
When the source User changes, the derived types follow along — a single source of truth holds.
as const + union instead of enum — TS enum produces a runtime object, so code stays in the bundle and it is hard to tree-shake. An as const object plus a union extracted from it is the recommended alternative:
const Role = {
Admin: "admin",
Editor: "editor",
Viewer: "viewer",
} as const;
type Role = typeof Role[keyof typeof Role]; // "admin" | "editor" | "viewer"
function grant(r: Role) { /* ... */ }
grant(Role.Editor); // usable as a value and as a literal
At runtime there is just one plain object, and the type is a narrow union, so both autocomplete and exhaustiveness checking work.
Restraint with as casts — as is a declaration that "I know better than the compiler", so it turns checking off. The justified places are narrow — right after validation, DOM event targets, and generic defaults:
// justified — attaching a type to a value that passed runtime validation
const data = JSON.parse(raw) as Config; // assumes validation with zod etc. on the next line
// justified — the DOM only hands you a general type, so you narrow it
const input = e.target as HTMLInputElement;
// inappropriate — a type guard is the right answer here
function isUser(v: unknown): v is User {
return typeof v === "object" && v !== null && "id" in v;
}
if (isUser(value)) value.id; // narrow with a guard instead of as User
If you "asserted where you could have narrowed", a type guard is usually correct. Since as turns off the compiler's safety net, each use should come with an answer to "why an assertion rather than a check".
8. Runtime validation and zod
Types live only at compile time. Anything coming from outside — JSON, form input, environment variables — has to be validated separately at runtime. The library that fills this slot most often:
import { z } from "zod"
const User = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().nonnegative()
})
type User = z.infer<typeof User> // static type extracted automatically
const u = User.parse(jsonFromApi) // throws on failure
Beyond zod (Colin McDonnell, 2020), valibot, ArkType, yup, and joi sit in the same slot.
| Library | First release | Note |
|---|---|---|
| zod | 2020 | TS-first. Most widely adopted. |
| yup | 2015 | Started as form validation. Often paired with Formik. |
| joi | 2012 | Originally for the hapi server. Established in node backends. |
| valibot | 2023 | Tree-shaking friendly. Function-level imports. |
| ArkType | 2023 | Parses TS expressions directly to build runtime types. |
9. Common pitfalls
The difference between any and unknown — any bypasses checking, while unknown means "identity unknown". The old behavior where catch variables were any becomes unknown under useUnknownInCatchVariables. It cannot be used until narrowed, which is the safe default.
Overuse of as assertions — assertions are a tool that turns checking off. They tend to grow in places that could use a validation function (parse) instead.
The enum trap — TS enum produces a runtime object and is hard to tree-shake. The frequently recommended alternative is an as const object plus a union type extracted from it.
Separation of types and runtime — types disappear at build time. Runtime branches must be built with validators like zod, or with typeof and in directly.
Type definition packages — JS libraries may not ship types. @types/<pkg> (DefinitelyTyped) is a separate package.
Closing thoughts
TypeScript strict + zod is the standard pair that covers both compile time and runtime. Keep any and as deliberately scarce and the type system delivers most of its value. Making every external input pass through one zod parse becomes a habit that pays off heavily in operational stability.
Next
- java21-modern
- python-async
TypeScript official site, TypeScript GitHub, TSConfig Reference, Strict Mode Family docs, DefinitelyTyped, TC39 ECMAScript proposals, zod, valibot, and ArkType are the references.