@studnicky/type-alias-invariants
Enforces one ordered contract for type aliases and imported type identity.
A retained local data alias is verified schema-derived pure data. Callable, constructor, runtime, object-brand, unknown-bearing, and other non-schema contracts use interfaces. Generic conditional, mapped, or indexed-access aliases remain type-level functions because interfaces cannot express these shapes. Canonical, codebase-owned named types are also consumed whole: Partial<X>, Pick<X, K>, Omit<X, K>, and structurally equivalent subsetting forms are rejected wherever they appear, not only inside a type alias declaration.
Fixable: No · Options: No · Suggested severity: error
Declaration contract
| Declaration | Required representation |
|---|---|
| JSON-Schema-expressible data | *Entity.Type = F<typeof Schema> for a verified schema-deriving F, under the complete entity suite |
| Callable or constructor | Interface call, method, or construct signature |
| Runtime object or provider seam | Interface |
| Readonly access policy | Interface |
| Unique-symbol primitive brand | Type alias that intersects the runtime primitive with a unique-symbol marker |
| Object brand or other runtime contract | Interface |
| Generic conditional, mapped, or indexed-access type-level function | Retained as a type alias; interfaces cannot express the shape |
| Non-generic conditional, mapped, indexed-access, or other non-schema computation | Interface where representable; otherwise named schema data plus a contract interface |
Primitive forwarding aliases, naked renames, generic forwarding aliases, import aliases, inline object aliases, unresolved references, contract-interface references, and non-JSON types do not establish canonical data.
A reference to a type-level function from another declaration still composes a contract portion exactly as an inline conditional, mapped, or indexed-access body would — only the type-level function's own declaration is exempt.
Schema provenance
Schema-derived local data uses a verified derivation: canonical FromSchema<typeof Schema>, a derivation symbol marked with @schemaDerivation, or a derivation symbol paired with its schema builder in the same package. The schema value is a module-scope unannotated const created from a const-asserted object literal or a builder call, and the resolved result is JSON-plain.
A declaration-file type, a generic type alias, or a matching structural shape is not schema provenance. Those facts do not establish which schema owns a local data model.
Provenance resolution follows TypeScript symbols through local declarations and imports with deterministic cycle and depth protection. An unresolved source is non-canonical; matching field shapes do not substitute for verified provenance.
The examples below use NodeStaticType<typeof Node>, the schema-deriving function this codebase builds entities with. It is one of many structurally-accepted forms, not the only one the rule recognizes — it is simply the mechanism a reader here will actually write.
External type composition
Types exported by TypeScript or by a declared direct dependency are external contracts. Import their canonical names and compose them when a local type needs additional structure. A naked alias remains invalid because it creates a local synonym instead of using the exported type directly.
import type { JSONSchema } from "json-schema-to-ts";
export type SchemaInputType = JSONSchema | { label: string };import type { JSONSchema } from "json-schema-to-ts";
export type LocalSchemaType = JSONSchema; // invalid: use JSONSchema directlyOnly direct dependency exports participate in this rule boundary. Transitive packages and private declarations do not establish external provenance.
Branded primitives
A primitive brand preserves its primitive runtime representation and uses a type alias, not an interface.
declare const UserIdBrand: unique symbol;
export type UserIdType = string & { readonly [UserIdBrand]: unique symbol };Rule coordination
The entityModelSuite uses interfaces for callable contracts and sets @typescript-eslint/prefer-function-type to off so the suite applies one declaration model.
Hand-written Type in an entity namespace
An exported Type in an *Entity namespace with no schema-deriving shape at all is still retained as canonical pure data — not rejected as inline object data — when the namespace's own Schema/Node value proves no structural derivation exists: it contains not/if/then/else anywhere, is an empty object ({}), or refines with anyOf alongside properties/required in the same object. This checks the schema itself, never a marker on Type; a schema that derives structurally still reports through the normal diagnostic paths below.
The same acceptance extends to a schema that composes another entity's schema by reference (items: OtherEntity.Schema, a spread, or an equivalent member reference this walk cannot derive into): the hand-written Type is justified only when it itself composes that other entity's already-justified .Type — through array, readonly, or union wrapping. A bare inline object literal or a non-entity imported alias in that position is still rejected; composing the referenced schema is not itself proof that any arbitrary hand-written shape is correct.
This predicate is shared, not reimplemented per rule: entity-file-shape and this rule consult the same syntax-only classification for a Type declaration in the file being linted, so a file cannot be accepted by one and rejected by the other on this question.
Union of named, schema-derived constituents
A top-level union where every constituent is a NAMED reference — a pure-data contract interface, or a schema-derived canonical Type/InputType alias — has no interface remedy: TypeScript has no syntax for a union-shaped interface, so aliasMustBeInterface cannot direct one. Each constituent is already a legitimate, independently-declared shape; the union exists only to name "one of these shapes" for a discriminated-union call site, and is retained as a type alias.
An inline object-literal constituent is a different case and is never exempted this way: a codebase-owned shape hiding inside the union still belongs in a named type, so a union with even one non-reference member is not retained by this exemption — it reports through the normal diagnostic paths, same as any other unverified data shape.
Diagnostic order
The rule has no subchecks or internal severity settings. ESLint's configured severity is the sole severity.
- Alias identity reports primitive aliases, naked aliases, generic forwarding aliases, and import aliases.
- Declaration shape reports callable, constructor, runtime, brand, and non-schema computations as interfaces. A generic conditional, mapped, or indexed-access alias is classified as a type-level function instead and is exempt from this check and from canonical provenance.
- Canonical provenance reports data-shaped aliases without verified schema provenance.
- Exported naming requires retained aliases — including type-level functions — to end in
Type. - Readonly output reports mutable data aliases that author access policy.
- Whole-type consumption reports
Partial/Pick/Omit, a structurally equivalent custom generic utility, or an inline mapped or indexed-access form that subsets a canonical, codebase-owned namedtype/interface, wherever the subsetting form appears — a type alias body, a function parameter, a variable annotation, or any other type position.
An earlier verdict suppresses later advice for the same alias. Structural equality, near-match, and subsumption are not identity evidence: two data types may share a shape while representing different semantics. The rule therefore performs no heuristic imported-shape comparison and does not infer canonical identity from broader or narrower shapes.
The companion all-types-are-entities rule then requires a retained canonical alias to use the exact entity form.
Readonly output policy
Pure-data aliases describe mutable data. Readonly access belongs on interface contracts and use sites. The rule detects readonly properties, index signatures, arrays, tuples, mapped output modifiers, Readonly<T>, ReadonlyArray<T>, exposed readonly defaults, and readonly alias references.
The rule reports readonly output without a fixer. Removing readonly changes the type's mutability contract even when the program still type-checks, so the appropriate repair depends on the design intent.
Generic constraints, callable inputs, conditional operands, keyof operands, indexed-access operands, mapped keys, and -readonly modifiers inspect or constrain data without authoring output policy.
For example, UserSnapshotInterface may expose readonly value: UserEntity.Type together with a refresh(): Promise<void> method. The entity owns the data shape; the interface owns readonly access and runtime behavior.
Pure-data portions inside a contract interface reference separately declared entity types.
✗ Incorrect
type HandlerType = (value: string) => void;type InlineDataType = { value: string };type ListType<T> = Array<T>;import { SchemaNode } from "@studnicky/entity/types";
import type { NodeStaticType } from "@studnicky/entity/types";
const Node = SchemaNode.defineString({ minLength: 1, type: "string" } as const);
type ValueType = NodeStaticType<typeof Node>;
export type ValueListType = readonly ValueType[];ValueType is verified schema-derived data; ValueListType fails on readonly, not on provenance — pure-data aliases describe mutable data.
interface FooInterface {
a: number;
b: string;
}
function accept(value: Partial<FooInterface>): void {}✓ Correct
import { SchemaNode } from "@studnicky/entity/types";
import type { NodeStaticType } from "@studnicky/entity/types";
const Node = SchemaNode.defineString({ minLength: 1, type: "string" } as const);
export type ValueType = NodeStaticType<typeof Node>;import { SchemaNode } from "@studnicky/entity/types";
import type { NodeStaticType } from "@studnicky/entity/types";
const Node = SchemaNode.defineString({ minLength: 1, type: "string" } as const);
type ValueType = NodeStaticType<typeof Node>;
export type ValueCollectionType = ValueType[] | null;type ConditionalType<T> = T extends string ? number : boolean;interface HandlerInterface {
(value: string): void;
}function accept<T>(value: Partial<T>): void {}Configuration
The rule takes no options and recognizes no comment, declaration-name, member-name, package, or path exemptions. Enable or disable the complete rule through flat configuration; individual invariant checks cannot be configured independently:
export default [
{
files: ["src/**/*.ts"],
rules: {
"@studnicky/type-alias-invariants": "error",
},
},
{
files: ["generated/**/*.ts"],
rules: {
"@studnicky/type-alias-invariants": "off",
},
},
];Related rules
all-types-are-entitiesrequires the exact entity declaration form.interface-must-be-contractrejects pure-data interfaces.interfaces-compose-named-typesextracts pure-data portions from contract interfaces.no-mixed-callable-shapesowns a union or intersection that mixes a callable constituent with data — a shapealiasMustBeInterfacecannot direct to an interface, since TypeScript has no syntax for a union-shaped interface.anyas a direct constituent is the one exception:classifyCallabilitytreatsanyas unconditionally callable, sotype X = any | { a: 1 }mixes identically to a genuine callable union, butanyis an escape hatch, not a shape with an interface remedy.type-alias-invariantsreports that mix directly rather than deferring tono-mixed-callable-shapes.