You've already used the type keyword a few times — type Severity = "critical" | "high" | "medium" | "low", type TestResult = [name: string, passed: boolean, duration: number]. This lesson formalises what that's doing: type aliases give a name to a type so you can reuse it across a project. Combined with the & operator, they let you build complex test data shapes by composing smaller ones. Aliases are how typed projects stay readable as they grow.
What a type alias is
A type alias is a name you give to a type. Once defined, you can use it anywhere a type is expected.
type StatusCode = number;
type TestName = string;
type UserId = string | number;
const code: StatusCode = 200;
const name: TestName = "Login test";
const id: UserId = "user-42";There's no runtime difference — StatusCode is number once compiled. The benefit is for humans: function signatures and type errors talk in your domain's vocabulary instead of generic primitives. getStatus(): StatusCode reads better than getStatus(): number.
Object type aliases
Aliases shine when the type is bigger than a primitive. An object shape, given a name, can be used in dozens of places:
type TestResult = {
name: string;
status: "passed" | "failed" | "skipped";
duration: number;
error?: string; // optional — only present if failed
};
const result: TestResult = {
name: "Login",
status: "passed",
duration: 1250,
};
function format(r: TestResult): string {
return `${r.name}: ${r.status} (${r.duration}ms)`;
}The error?: syntax marks the field as optional — result.error is string | undefined and may be omitted. You'll meet optional fields again properly in chapter 3.
Intersection types — combining shapes
Intersection types use the & operator and read as "AND." The resulting type has every field from every component:
type Timestamps = {
createdAt: string;
updatedAt: string;
};
type User = {
name: string;
email: string;
};
type UserWithTimestamps = User & Timestamps;
// Has all four fields: name, email, createdAt, updatedAt
const u: UserWithTimestamps = {
name: "Alice",
email: "alice@example.com",
createdAt: "2026-01-12T10:00:00Z",
updatedAt: "2026-05-04T08:14:00Z",
};Try omitting one of the four fields and the compiler complains — "Property 'updatedAt' is missing in type."
Union vs intersection — "or" vs "and"
Two operators that look similar and do the opposite thing:
-
Union (
|) — the value is one type OR another.type Id = string | number; // either a string or a number -
Intersection (
&) — the value has fields from one type AND another.type Both = User & Timestamps; // every field from both
A useful mnemonic: | is broader (more values possible), & is narrower (more fields required). They sound symmetric but point in opposite directions.
When to alias and when to inline
A type alias earns its keep when:
- You use the same shape in more than one place. Aliasing means renames happen once.
- The type is complex enough to be hard to read inline — long unions, nested objects, or anything where the annotation would dwarf the function signature.
- The alias name adds meaning beyond the structure.
UserIdsays more thanstring, even if the underlying type is identical.
Inline types are fine for one-off shapes used in a single function. Don't alias just because aliasing is available.
// Fine inline — used once, simple
function logEntry(entry: { time: string; message: string }) { /* ... */ }
// Worth aliasing — reused, conveys meaning
type ApiError = { code: string; message: string; retryable: boolean };
function reportError(e: ApiError) { /* ... */ }
function isRetryable(e: ApiError): boolean { return e.retryable; }Naming convention
Type aliases use PascalCase — TestResult, ApiResponse, UserCredentials. Same convention as classes and interfaces (which you'll meet next chapter). The convention helps readers spot at a glance whether a name refers to a type (TestResult) or a value (testResult).
Composing test data with intersections
A real test suite often has layered types — every entity has timestamps, every user has permissions, every request has a correlation id. Intersection lets you build these by composition rather than copy-paste.
- – name: string
- – email: string
- – createdAt: string
- – updatedAt: string
- role: 'admin' | 'tester' –
- scopes: string[] –
In code:
type Timestamps = { createdAt: string; updatedAt: string };
type User = { name: string; email: string };
type Permissions = { role: "admin" | "tester"; scopes: string[] };
type FullUser = User & Timestamps & Permissions;
const alice: FullUser = {
name: "Alice",
email: "alice@example.com",
createdAt: "2026-01-12T10:00:00Z",
updatedAt: "2026-05-04T08:14:00Z",
role: "admin",
scopes: ["users:read", "users:write"],
};You can build TestCase, TestSuite, and TestRun the same way — small named pieces, intersected into the bigger shape your code actually deals with. The same intersection scales: a fixture loader returning User & Timestamps, a test factory adding Permissions only for admin scenarios, an analytics call adding RequestId.
A note on type vs interface
You'll learn interface properly in chapter 3. The short version: for object shapes, type and interface are interchangeable for most uses. interface supports declaration merging (multiple interface User {} blocks in different files merge into one); type supports unions, tuples, mapped types, and conditional types — things interface can't express. A pragmatic rule many teams use: prefer interface for object shapes, reach for type when the shape isn't a plain object (unions, intersections, tuples, mapped types).
⚠️ Common mistakes
- Aliasing for a single-use type.
type EnvName = "dev" | "staging"; function deploy(env: EnvName) {}— fine ifEnvNameis reused. If onlydeployever takes it, inlinefunction deploy(env: "dev" | "staging") {}. Aliases are documentation; one-off aliases are noise. - Confusing
&with|.type Result = Success & Failureis not "either success or failure" — it's "has all the fields of both," which is usually nonsense. For "either," use|. The two operators read similarly in English but mean opposite things in the type system. - Forgetting that intersection requires every property.
type A & Bis a value with all fields from both. If two intersected types declare the same property with different types (e.g., both haveidbut one isstringand the other isnumber), TypeScript narrows the field to the impossible intersection (never) and your variable becomes unusable. Keep intersected types disjoint.
🎯 Practice task
Compose a type-safe test data model. 20-30 minutes.
- In your
ts-for-qa/srcfolder, createmodel.ts. - Declare these primitive aliases:
type TestId = stringtype DurationMs = numbertype TestStatus = "passed" | "failed" | "skipped"
- Declare three small object aliases:
type TestCase = { id: TestId; name: string; status: TestStatus; duration: DurationMs }type Timestamps = { startedAt: string; finishedAt: string }type Environment = { browser: "chromium" | "firefox" | "webkit"; baseUrl: string }
- Compose them:
type TestRun = TestCase & Timestamps & Environment. The resulting type has every field from all three. - Create one
TestRunvalue, populate every field, and write a functionsummarise(run: TestRun): stringthat returns a one-line summary. - Run with
npx ts-node src/model.ts. Confirm the output. - Try the union mistake. Change
TestRun = TestCase | Timestamps | Environment(note the|). Hover over a field access — TypeScript narrows the type to only the intersection of fields, and most accesses become errors. Read why, then revert. - Stretch: add
type RetryInfo = { attempts: number; lastError?: string }and intersect it intoTestRunonly when retries are enabled. Notice how the optional?onlastErrorlets you omit the field while still having it typed.
You've now seen every basic type-system tool — primitives, arrays, tuples, unions, literals, aliases, intersections. The next chapter introduces interface, the canonical way to describe object shapes for the rest of your career.