What a type is
A type is a set of values. boolean is the set containing false and true. number is the set of all numbers. 1000 is a set with one element. TypeScript's checker proves, without running your program, that every value flowing into a location belongs to that location's set, and every error it ever shows you is a membership complaint.
That claim needs one piece of machinery to test properly, and this page builds it in the second step: a way to state "this line is a type error" inside a file that still checks.
Create programs/what-a-type-is.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import { assertEquals, assertThrows } from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
a type is a set, and assignment is membership
TypeScript checks values flowing from a source to a target. The source is the value being supplied: a literal, an expression, or anything read. The target is the place receiving it: a variable, parameter, property, or return position. The source type says which values might be supplied; the target type says which values are accepted. Start with one annotated target and two flows into it:
Deno.test("a type is a set, and assignment is membership", () => {
let count: number;
count = 8;
assertEquals(count, 8);
count = count * 2;
assertEquals(count, 16);
});
Check programs/what-a-type-is.test.ts
running 1 test from ./programs/what-a-type-is.test.ts
a type is a set, and assignment is membership ... ok (0ms)
ok | 1 passed | 0 failed (1ms)
Both flows were members. 8 is in the set number, and count * 2 produces a value from the same set. Now try a value from outside the set. Add one line and save:
Deno.test("a type is a set, and assignment is membership", () => {
let count: number;
count = 8;
assertEquals(count, 8);
count = "yes";
});
Check programs/what-a-type-is.test.ts
TS2322 [ERROR]: Type 'string' is not assignable to type 'number'.
count = "yes";
~~~~~
at file:///programs/what-a-type-is.test.ts:8:5
error: Type checking failed.
info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.
Read the message with one substitution: "assignable to" means "a member of". The string "yes" is not a member of the target type number, so the assignment is rejected. Every checker message on every page of these references is this same sentence about different sets.
Delete the line for now. The claim it demonstrated, that the checker rejects the flow, currently lives only in this page's prose, and the next step brings it back into the file permanently.
a pinned type error runs anyway
Sometimes the fact you want to state is "this line is a type error." The directive for that is @ts-expect-error, and it is assertThrows for the type level: it succeeds when the next line fails to check, and it becomes an error itself when the next line stops failing. Both halves of that are about to be evidence.
The directive also opens a door: a line the checker rejects can now run, and running it answers a question the checker cannot. What is count at run time, when the annotation says number and the value is a string? Predict, then save:
Deno.test("a pinned type error runs anyway", () => {
// @ts-expect-error: the string "8" is not in the set number
const count: number = "8";
assertEquals(typeof count, "number");
});
Check programs/what-a-type-is.test.ts
running 2 tests from ./programs/what-a-type-is.test.ts
a type is a set, and assignment is membership ... ok (1ms)
a pinned type error runs anyway ... FAILED (8ms)
ERRORS
a pinned type error runs anyway => ./programs/what-a-type-is.test.ts:12:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- string
+ number
FAILURES
a pinned type error runs anyway => ./programs/what-a-type-is.test.ts:12:11
FAILED | 1 passed | 1 failed (10ms)
error: Test failed
typeof count is "string". The annotation did nothing at run time, and this failure is the most important fact on the page. A TypeScript file leads two lives.
- Deno checks it as TypeScript: the source is read, the types are compared, and membership is enforced. That is where the pinned error lives.
- Deno runs it as JavaScript, after stripping every annotation out. The
: numberis gone before the first line executes, so there is no moment at which the running program consults it, and the string flows through untouched.
All enforcement happens in the reading. Correct the prediction to "string":
a pinned type error runs anyway ... ok (0ms)
Now the other half of the directive's contract. Repair the "error" it covers, so the next line checks cleanly, and save:
Deno.test("a pinned type error runs anyway", () => {
// @ts-expect-error: the string "8" is not in the set number
const count: number = 8;
assertEquals(typeof count, "string");
});
Check programs/what-a-type-is.test.ts
TS2578 [ERROR]: Unused '@ts-expect-error' directive.
// @ts-expect-error: the string "8" is not in the set number
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at file:///programs/what-a-type-is.test.ts:13:5
error: Type checking failed.
info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.
The directive expected an error, found none, and became the error. That symmetry is what makes it trustworthy: a pinned rejection is re-proved on every check, and if the language or the code ever changes so the line is legal, the file refuses to check until someone looks. From here on, every claim of the form "the checker rejects this" ships as a @ts-expect-error line in the entry, permanently. Restore "8" and the "string" prediction.
a function boundary is where annotations earn their keep
A function turns one flow into two. Its parameter type is the target for every argument supplied by a caller, and its return type is the target every return statement must satisfy:
Deno.test("a function boundary is where annotations earn their keep", () => {
function toString(num: number): string {
return String(num);
}
assertEquals(toString(8), "8");
// @ts-expect-error: the string "8" is not in the parameter's set
assertEquals(toString("8"), "8");
});
a function boundary is where annotations earn their keep ... ok (0ms)
Walk the two calls.
toString(8)supplies a member ofnumberto the parameter, and the return keeps its promise:String(num)produces a member ofstring.toString("8")is pinned, so the checker's rejection is on record. And because annotations are stripped before the run, the call executes anyway,String("8")happily produces"8", and the assertion passes. Same lesson as the previous step: the run tolerates what the reading rejects, so the reading is the only guard there is.
That is why the advice at the end of this page says to annotate boundaries first. A parameter's set is checked at every call site you will ever write, which is the highest return any annotation pays.
the checker infers where you write nothing
Annotations are how you state intent. They are not what switches the checking on. Write a file with no annotation anywhere near the mistake, and the checker still rejects it: typed bare, value.length below draws TS18047: 'value' is possibly 'null'. The checker read the const, inferred the one-element set null, and knew no member of that set has a length.
Pin the rejection, and let the line run inside assertThrows to see what the checker was protecting against:
Deno.test("the checker infers where you write nothing", () => {
const value = null;
assertThrows(
() => {
// @ts-expect-error: no annotation anywhere, and the checker still rejects
return value.length;
},
TypeError,
"Cannot read properties of null (reading 'length')",
);
const eight = 8;
const double = eight * 2;
assertEquals(double, 16);
});
the checker infers where you write nothing ... ok (0ms)
Follow the chain.
const value = nullcarries no annotation, so the checker infers the type from the initializer: the setnull.value.lengthasks a member of that set for a property, and the nothing-twice page's reading a property throws only for these two established what that does at run time: aTypeError. The checker rejected statically exactly the line that crashes dynamically, and theassertThrowsshows the crash it prevented.eightanddoubleshow the same inference on the happy path: no annotations, and the checker knows both are members ofnumberthroughout.
Let inference carry your locals. An annotation on const eight = 8 would only restate what the checker already knows.
typeof reports the sets that exist at run time
JavaScript has its own types, present while the program runs, and typeof reports them. Line them all up:
Deno.test("typeof reports the sets that exist at run time", () => {
assertEquals(typeof undefined, "undefined");
assertEquals(typeof true, "boolean");
assertEquals(typeof 8, "number");
assertEquals(typeof 10n, "bigint");
assertEquals(typeof "yes", "string");
assertEquals(typeof Symbol(), "symbol");
assertEquals(typeof {}, "object");
assertEquals(typeof null, "object");
assertEquals(typeof (() => 1), "function");
const noValue: undefined = undefined;
assertEquals(noValue, undefined);
});
typeof reports the sets that exist at run time ... ok (0ms)
These are the dynamic types: you learn them by running. The checker's types are static: it learns them by reading. Three lines in the list deserve a second look.
typeof nullis"object", the 1995 bug with tenure from the nothing-twice page'stypeof nullsays object, and it is a bug.- Functions report
"function", although the values-and-references page's list of the seven primitives puts them squarely among the objects. The operator volunteers a distinction the type system does not make. const noValue: undefined = undefinedis one token appearing twice with two meanings. Right of the=, it is the valueundefined, a thing that exists at run time. After the colon, it is the typeundefined, the one-element set that value lives in, a thing that exists only while checking. Which language you are reading depends on which side of the colon you are on.
subtype means subset
1000 is a valid type: the set whose only member is the number 1000. as const asks the checker for that narrowest set instead of widening to number, and it is the spelling Deno's own prefer-as-const lint rule asks for. Once a value belongs to a one-element set, watch which direction it may flow:
Deno.test("subtype means subset", () => {
const thousand = 1000 as const;
const n: number = thousand;
assertEquals(n, 1000);
const approximate = Math.round(1500.4);
// @ts-expect-error: number is not a subset of the one-element set 1000
const exact: 1000 = approximate;
assertEquals(exact, 1500);
});
subtype means subset ... ok (0ms)
Both directions, one rule.
thousandhas type1000, and every member of the set1000is a member of the setnumber, so the flow intonis allowed. A subset flows into its superset without complaint.approximatehas typenumber, andnumberhas members other than1000, so the flow into the target type1000is rejected, and the pin records it. The runtime line beneath shows what the reading prevented: the value is1500, sitting in a slot whose type swears it is1000.
That is the entire meaning of subtyping in TypeScript: subset. No hierarchy diagrams required. One-element types like 1000 and "yes" look useless alone; they are the atoms unions are built from, and they collect their payoff in unions and narrowing, starting with a union is a union of sets.
membership is decided by shape
Object types describe shape. { name: string } is the set of all objects with a name property holding a string, and membership asks nothing about declared names, classes, or ancestry:
Deno.test("membership is decided by shape", () => {
function greet(person: { name: string }): string {
return `hello, ${person.name}`;
}
const ada = { name: "ada", born: 1815 };
assertEquals(greet(ada), "hello, ada");
// @ts-expect-error: a fresh literal may only specify known properties
assertEquals(greet({ name: "ada", born: 1815 }), "hello, ada");
});
membership is decided by shape ... ok (0ms)
The same data, two verdicts, and the difference is worth understanding precisely.
adahas anameholding a string, soadais a member of{ name: string }. The extrabornproperty does not disqualify it, by the same subset logic as the previous step: the set of objects withnameandbornsits inside the set of objects withname.- The second call supplies a fresh literal directly to the
{ name: string }parameter, and the checker flags it:TS2353: Object literal may only specify known properties, and 'born' does not exist in type '{ name: string; }'.Membership did not change. The checker is second-guessing the literal, not the set, and its logic is sound: a literal written in place can never be read by anyone else, so an extra property in it can serve no one. It is either a typo or a misunderstanding, and the checker chooses to say so.
The pinned second call still runs, still greets ada, and still ignores born, which is the point of the carve-out: the run never needed the property, so writing it was at best noise.
In practice
- Read a type as a set of values and checking as membership in that set.
- Annotate boundaries such as parameters, return types, and exported values, where the type is checked at every use site.
- Let inference carry local variables instead of restating what the checker already knows.
- Read “is not assignable to” as “is not a member of.”
- Pin an intentional type error with
@ts-expect-errorin a checked file so the claim is proved on every check.