Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

deno-lint-plugin-explicit-types

TypeScript style rules for deno lint. Types should get written out, not derived.

Setup

// deno.json
{
  "lint": {
    "plugins": [
      "jsr:@cunarist/deno-lint-plugin-explicit-types/concrete",
      "jsr:@cunarist/deno-lint-plugin-explicit-types/naming",
      "jsr:@cunarist/deno-lint-plugin-explicit-types/absence"
    ]
  }
}

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.

/concrete — write the type out

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.

/naming — names carry the contract

// 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.

/absence — absence is authored

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 it

One caveat before adopting: a key missing from JSON is undefined at runtime, not null, so returning | null means normalising where you parse data.

How opinionated is this?

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.

Pairs well with

no-explicit-any and no-non-null-assertion, both built into Deno. This plugin leaves them alone rather than duplicating them.

Known limits

  • No rule options. Deno's plugin API cannot configure a rule.
  • One file at a time. No cross-file resolution, so no-utility-types sees Partial<T> where it is written but not through a local alias.
  • Syntax, not types. no-type-assertion sees x as T, not an assertion laundered through a function signature.
  • Names you didn't choose are left alone. no-upper-snake-object-keys only reports UPPER_SNAKE_CASE, so custom element tags, data-* attributes, HTTP headers and library keymaps pass. Unquoted snake_case is Deno's built-in camelcase rule's job.

License

MIT.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages