TypeScript style rules for deno lint. Types should get written out, not
derived.
Three plugins, added separately. Adding one turns on every rule in it, so turn one off by id:
{ "lint": { "rules": { "exclude": ["explicit-concrete/no-type-assertion"] } } }Or import the rules and build your own plugin. Each entry point exports its
rules individually, plus concreteRules, namingRules, and absenceRules for
taking a whole set:
// lint.ts
import { concreteRules } from "jsr:@cunarist/deno-lint-plugin-explicit-types/concrete";
import { noEnum } from "jsr:@cunarist/deno-lint-plugin-explicit-types/naming";
const plugin: Deno.lint.Plugin = {
name: "my-types",
rules: { ...concreteRules, "no-enum": noEnum },
};
export default plugin;Your diagnostics then read my-types/no-enum.
A type is written, not computed. If a reader has to run the compiler in their head to know what a value is, that's a defect.
// BAD - go read TaskSnapshot and apply two transforms
type Draft = Partial<Omit<TaskSnapshot, "id">>;
// GOOD
interface Draft {
label: string | null;
state: TaskState | null;
}| Rule | Catches |
|---|---|
no-utility-types |
Partial, Record, ReturnType, 19 more |
no-keyof |
A union nobody wrote down |
no-typeof-type |
A type read backwards out of a value |
no-mapped-types |
{ [K in Keys]: T } |
no-index-signatures |
{ [key: string]: T }, where every read lies |
no-inline-object-types |
{ a: string } with no name |
no-conditional-types |
T extends U ? A : B, and infer |
require-return-type |
A function whose contract is in its body |
no-type-assertion |
x as T, overruling the compiler |
as const and satisfies are allowed — both narrow rather than overrule.
Indexed access is too: Options["retries"] is how you name a type a library
already wrote.
// BAD
enum Status {
Pending,
Done,
}
// GOOD
type Status = "PENDING" | "DONE";| Rule | Catches |
|---|---|
pascal-case-types |
A type name that isn't PascalCase |
upper-snake-string-unions |
"pending" where "PENDING" fits |
no-upper-snake-object-keys |
A union used as object keys |
no-enum |
enum, including const enum |
These interlock: no-enum removes the construct, upper-snake-string-unions
governs the union that replaces it, and no-upper-snake-object-keys keeps those
members out of object keys. So turning a Status into a value is a switch,
not an object you index into — and a switch stops compiling the day someone
adds a member.
undefined is what the language hands back when nobody said anything. null is
what somebody wrote. Give absence one spelling per position, and never two at
once. ? is a good spelling — these rules keep it, they just stop it doubling
up with the undefined keyword or with | null.
// BAD
interface StoredSettings {
apiKey?: string | null; // no-optional-null - two ways to be empty
zoomLevel: number | undefined; // no-undefined-type
}
// GOOD - `?` on a member, `| null` where `?` cannot go
interface StoredSettings {
apiKey?: string;
zoomLevel: number | null;
}| Rule | Catches |
|---|---|
no-undefined-type |
The undefined keyword in any type |
no-optional-null |
a?: T | null, two ways to say empty |
? is the undefined type, so there is no reason to write the keyword as well.
And a value that can be empty in two ways has no definite empty value. A member
or parameter says absence with ?; a return type or variable, which cannot take
?, says it with | null; nothing says it twice:
interface Job {
cursor?: string;
} // GOOD
function render(host?: Element): void {} // GOOD
function findNote(id: string): Note | null {} // GOOD
function open(path?: string | null): void {} // BAD - pick one
function open(path?: string | undefined): void {} // BAD - `?` already said itOne caveat before adopting: a key missing from JSON is undefined at runtime,
not null, so returning | null means normalising where you parse data.
Mainstream: pascal-case-types, no-enum, no-type-assertion,
no-index-signatures.
Deliberately stronger than most TypeScript codebases: no-utility-types,
no-keyof, no-mapped-types, no-typeof-type, no-conditional-types,
no-inline-object-types. These push toward types that are declared rather than
computed — closer to writing every struct out, as Go does. What you read is what
the type is.
Strongest of all: /absence. It is the only preset that changes runtime code
rather than annotations.
no-explicit-any and no-non-null-assertion, both built into Deno. This plugin
leaves them alone rather than duplicating them.
- No rule options. Deno's plugin API cannot configure a rule.
- One file at a time. No cross-file resolution, so
no-utility-typesseesPartial<T>where it is written but not through a local alias. - Syntax, not types.
no-type-assertionseesx as T, not an assertion laundered through a function signature. - Names you didn't choose are left alone.
no-upper-snake-object-keysonly reports UPPER_SNAKE_CASE, so custom element tags,data-*attributes, HTTP headers and library keymaps pass. Unquotedsnake_caseis Deno's built-incamelcaserule's job.
MIT.