Problem
A type assertion is gated by exactly one check, checkAssertionDeferred (tsc/internal/checker/checker.go:12542-12556 at the pinned compiler revision 879f9867ac455404e75759dd1739281cf6aa7f85), quoted in full:
func (c *Checker) checkAssertionDeferred(node *ast.Node) {
typeNode := node.Type()
exprType := c.getRegularTypeOfObjectLiteral(c.getBaseTypeOfLiteralType(c.assertionLinks.Get(node).exprType))
targetType := c.getTypeFromTypeNode(typeNode)
if !c.isErrorType(targetType) {
widenedType := c.getWidenedType(exprType)
if !c.isTypeComparableTo(targetType, widenedType) {
errNode := node
if typeNode.Flags&ast.NodeFlagsReparsed != 0 {
errNode = typeNode
}
c.checkTypeComparableTo(exprType, targetType, errNode, diagnostics.Conversion_of_type_0_to_type_1_may_be_a_mistake_because_neither_type_sufficiently_overlaps_with_the_other_If_this_was_intentional_convert_the_expression_to_unknown_first)
}
}
}
So expr as T is accepted iff
isTypeComparableTo(T, widened(normalized(expr))) || isTypeComparableTo(normalized(expr), T)
where normalized(t) = getRegularTypeOfObjectLiteral(getBaseTypeOfLiteralType(t)). The first disjunct is evaluated first and short-circuits.
isTypeComparableTo(s, t) is isTypeRelatedTo(s, t, comparableRelation) (relater.go:161-163). For the comparable relation, isTypeRelatedTo probes both argument orders before any structural work (relater.go:180):
if relation == c.comparableRelation && target.flags&TypeFlagsNever == 0 && c.isSimpleTypeRelatedTo(target, source, relation, nil) || c.isSimpleTypeRelatedTo(source, target, relation, nil) {
and isSimpleTypeRelatedTo short-circuits in its first two branches (relater.go:205-213):
func (c *Checker) isSimpleTypeRelatedTo(source *Type, target *Type, relation *Relation, errorReporter ErrorReporter) bool {
s := source.flags
t := target.flags
if t&TypeFlagsAny != 0 || s&TypeFlagsNever != 0 || source == c.wildcardType {
return true
}
if t&TypeFlagsUnknown != 0 && !(relation == c.strictSubtypeRelation && s&TypeFlagsAny != 0) {
return true
}
That is the whole bug surface. any as the relation's target, never as its source, and unknown as its target each return true before the two real types are ever compared. Because line 180 probes both orders, a top or bottom type on either side of the assertion reaches one of those branches:
| written |
operand type the check sees |
disjunct that fires, and the branch inside it |
what actually got compared |
x as any |
whatever x is |
first; t&Any, assertion target in the relation's target position |
nothing |
x as never |
whatever x is |
first; s&Never, assertion target in the relation's source position |
nothing |
x as unknown |
whatever x is |
first; t&Unknown, assertion target in the relation's target position |
nothing |
x as unknown as T |
unknown |
first; the reversed probe at :180 puts unknown in the target position, so t&Unknown |
unknown with T |
x as any as T |
any |
first; t&Any |
any with T |
JSON.parse(s) as T |
any |
first; t&Any |
any with T |
In every row after the first three, the user's real type and the target they claimed never met. TypeScript reports nothing, and no other diagnostic surfaces the result. TS2352 is the diagnostic that would otherwise have fired, and its own remediation text — "If this was intentional, convert the expression to unknown first" — tells users how to reach the state this rule is about.
What breaks at runtime. The asserted type is trusted by everything downstream while nothing established it. Three vetted examples from the private codebase in Where this came up: a Schema.Codec<A, I> laundered into a Schema.Codec<object, unknown>, so a decoder runs against the wrong codec; an object literal laundered into a service interface whose run is declared Effect<never, SocketServerError, R> while the literal returns Effect<never, never, never>, silently erasing an error channel the rest of the program never handles; and a value declared unknown asserted to number and fed into Math.max, where a string operand yields NaN.
How this differs from unsafeEffectTypeAssertion. That rule requires both the operand and the target to parse as an Effect, Stream or Layer, then compares the E and R channels (internal/rules/unsafe_effect_type_assertion.go:96-120). It is about narrowing within Effect's channels on a conversion the checker already accepted; this proposal is about the conversion never being checked at all, in any type. To be precise about the evidence: unsafeEffectTypeAssertion produced zero diagnostics over both measured corpora, so the corpora cannot demonstrate non-overlap. The argument for non-overlap is structural — that rule inspects only Effect types, and this one never inspects Effect types at all.
A separate, smaller bug the measurement exposed. Inserting as unknown silences unsafeEffectTypeAssertion entirely. With both rules at error against effect@4.0.0-beta.107, over a file whose line 5 is program as Effect.Effect<string, never, never> and whose line 8 writes the same narrowing as program as unknown as Effect.Effect<string, never, never>, the whole output is:
src/a.ts(5,23): error TS377075: This type assertion unsafely narrows the error or requirements channels. effect(unsafeEffectTypeAssertion)
exit=2
Line 8 is reported by neither rule: unsafeEffectTypeAssertion computes the outer assertion's operand type, gets unknown, fails to parse it as an Effect, and returns. The rule proposed here is also silent, correctly by its own predicate — the checker accepts the direct form, so nothing was laundered. The fix belongs in the existing rule: strip a chain of assertions through any/unknown/never before parsing the operand. Not part of this proposal; recorded because the measurement found it.
Bad — compiles cleanly, the rule should flag this
Verified: these declarations and assertions, with the explanatory comments removed, compile at exit 0 under "strict": true with typescript@7.0.2 patched by @effect/tsgo@0.45.0, against effect@4.0.0-beta.107, with unsafeTypeAssertion already pinned to "error" (inert, because the rule does not exist). Against a build carrying the rule the same file exits 2 and reports all five sites across all three codes.
// RULE: unsafeTypeAssertion
declare const rawJson: string
declare const items: ReadonlyArray<number>
interface Payload {
readonly id: number
readonly name: string
}
type NonEmpty<A> = readonly [A, ...Array<A>]
// BAD: shape 1. The assertion erases the type of the whole expression. The checker
// accepts it because `isSimpleTypeRelatedTo` returns true on `t&TypeFlagsAny`, so
// `Payload` was never compared with anything.
export const shape1 = (value: Payload): unknown => value as any
// BAD: shape 2. `number` and `Payload` do not overlap, so `count as Payload` on its
// own is a TS2352 error. Routing through `unknown` satisfies the relation, and the
// two real types are never compared.
export const shape2 = (count: number): Payload => count as unknown as Payload
// BAD: shape 2. `items.slice()` is `number[]`, which may be empty; the target says it
// cannot be. `shape2b()[0]` is typed `number` and is `undefined` at runtime.
export const shape2b = (): NonEmpty<number> => items.slice() as any as NonEmpty<number>
// BAD: shape 3. `JSON.parse` returns `any`, so the relation succeeds on `t&TypeFlagsAny`
// and nothing checked that the parsed value has `id` or `name`.
export const shape3 = (): Payload => JSON.parse(rawJson) as Payload
// BAD: shape 3. A caught value is `unknown` under `useUnknownInCatchVariables`, and the
// assertion is carried into the return type, so every caller trusts it.
export const shape3b = (cause: unknown): Error => cause as Error
Good
// RULE: unsafeTypeAssertion
import * as Schema from "effect/Schema"
declare const rawJson: string
const Payload = Schema.Struct({ id: Schema.Number, name: Schema.String })
// GOOD: the boundary is decoded, so the type is earned rather than claimed, and the
// decoder's result type is what the function returns.
export const decoded = (): { readonly id: number; readonly name: string } =>
Schema.decodeUnknownSync(Payload)(JSON.parse(rawJson))
// GOOD: a type guard narrows `unknown`, and the checker carries the narrowing.
export const guarded = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause)
Zero unsafeTypeAssertion diagnostics on this file under the prototype.
Proposed rule behavior
What to build. One rule, unsafeTypeAssertion, covering all three shapes under three codes. Every number in this issue was produced by a prototype of exactly that. The Precision, volume, and one recommendation section recommends splitting shape 1 into a second rule and states precisely what would change if you take that option; if you do, build the split version, but read the numbers here as describing the combined rule.
Group correctness, DefaultSeverity: off, SupportedEffect: ["v3", "v4"] — matching the two existing soundness rules. The rule is Effect-agnostic and gates on neither the Effect version nor the presence of an effect import. Every message ends with effect(unsafeTypeAssertion) and is declared "category": "Warning" in internal/diagnostics/effectDiagnosticMessages.json. The codes below are the ones the prototype used and are free as of main (current maximum 377133).
Definitions used throughout.
- transparent wrappers =
{KindParenthesizedExpression, KindNonNullExpression}, stripped identically when walking down to an operand and when walking up to an enclosing node. ast.SkipParentheses is not sufficient — it handles only the first kind. Every up-walk and down-walk in this rule must strip the same set; that single invariant is what keeps chains from reporting twice.
- top/bottom = a type carrying
TypeFlagsAny, TypeFlagsUnknown or TypeFlagsNever.
- concrete = not top/bottom and not the error type. The error-type test must come first, because
c.errorType is newIntrinsicType(TypeFlagsAny, "error") (checker.go:1023) and so satisfies TypeFlagsAny.
Decision procedure, applied to each KindAsExpression / KindTypeAssertionExpression node. operand is node.Expression(); targetType is the type of the assertion node; operandType is GetTypeAtLocation(operand), which is flow-narrowed.
- Type node is a
const type reference (ast.IsConstTypeReference) → skip.
ast.IsInJSFile(node) or typeNode.Flags & ast.NodeFlagsReparsed != 0 → skip.
targetType is the error type → skip.
targetType carries TypeFlagsUnknown → skip (widening to unknown is sound).
targetType carries TypeFlagsAny or TypeFlagsNever:
a. if node, after stripping transparent wrappers upward, is the operand of an enclosing assertion → skip (the outer node reports the chain);
b. if node is the object of a property or element access → skip;
c. otherwise → report shape 1.
targetType carries TypeFlagsTypeParameter → skip.
targetType is an object type with at least one property, all optional and typed unknown or any → skip.
node is the object of a property or element access → skip.
- Let
inner = operand with transparent wrappers stripped. If inner is an assertion whose own type is top/bottom and not the error type:
a. walk down from inner through assertion links whose own type is top/bottom, stopping at the first node that is either not an assertion or an assertion whose type is concrete. Call it original;
b. originalType is the error type → skip;
c. originalType carries TypeFlagsAny or TypeFlagsUnknown → report shape 3 (this is what stops as unknown being a suppression);
d. the checker would have accepted original as target directly → skip;
e. otherwise → report shape 2.
operandType is the error type → skip.
operandType carries TypeFlagsAny or TypeFlagsUnknown → report shape 3.
- Otherwise → skip.
Shape 1 — x as any, <any>x, x as never. Code TS377136.
Message: Asserting to `{0}` disables type checking for this expression. Remove the assertion, or narrow the value with a type guard. — {0} is the target type.
Step 5a is not merely deduplication: it also silences. declare const x: string; x as any as string reports nothing at all, because shape 1 is suppressed on the inner node and step 9d accepts the outer. That is correct — the double cast is redundant rather than unsound — but it is worth knowing.
x as never is worth calling out: the checker accepts it for every operand via the s&TypeFlagsNever branch (relater.go:208), so it is a finding no existing diagnostic duplicates. Verified rather than assumed — the fixture containing value as never type-checks with zero error TS of its own.
Shape 2 — a double assertion through a top or bottom type. Code TS377137.
Message: This assertion routes through `{0}`, so the checker compared `{1}` with `{0}` rather than with `{2}`. A type guard or `Schema.is` checks the value instead. — {0} is the intermediate (top/bottom) type, {1} the original operand's type, {2} the final target. Declarative on purpose: TS2352 tells users to insert as unknown, so the message states what happened rather than implying the user did something unsanctioned.
Step 9d re-applies checkAssertionDeferred's predicate verbatim, with the original operand's type substituted for the laundered one:
accepted = isTypeComparableTo(target, getWidenedType(norm)) || isTypeComparableTo(norm, target)
where norm = getRegularTypeOfObjectLiteral(getBaseTypeOfLiteralType(originalType))
This is the rule's one formal guarantee, and it is worth stating exactly: shape 2 never reports a conversion the checker would have accepted with the intermediate assertion removed. Shapes 1 and 3 carry no such guarantee; they are unconditional positional findings whose justification is policy, not the checker's verdict.
Argument order matters. isTypeComparableTo is asymmetric: relater.go:180 guards the reversed probe with target.flags&TypeFlagsNever == 0, so isTypeComparableTo(X, never) is false while isTypeComparableTo(never, X) is true. Inverting the disjuncts changes the verdict for never participants and for every structural comparison. (It changes nothing for unknown, where both orders succeed.)
Step 9a's stopping rule is load-bearing. In (x as Foo) as any as Bar the as Foo link was already compared by the checker, so Foo — not typeof x — is what the outer assertion launders. Stopping at x instead makes this a false negative whenever typeof x is unknown, because the predicate then succeeds on t&TypeFlagsUnknown. Measured: the prototype reports compared `Foo` with `any` rather than with `Bar` .
The converse is worth stating, because it is the other half of step 5a: a chain is deduplicated only across its top/bottom links, not across its concrete ones. Each link whose own target is concrete is judged on its own merits, so one source line can legitimately carry more than one diagnostic. Measured on declare const x: unknown; (x as Foo) as any as Bar, the prototype emits two: shape 2 on the outer node (compared `Foo` with `any` rather than with `Bar` ) and shape 3 on the inner x as Foo (Asserting `unknown` to `Foo` ). Both are real and neither subsumes the other.
Shape 3 — x as T where x is any or unknown and T is concrete. Code TS377138.
Message: Asserting `{0}` to `{1}` performs no check at runtime. Narrow the value with a type guard, or decode it with a `Schema` decoder. — {0} is the operand's type, {1} the target. The remedy names a type guard first and a decoder second deliberately: this fires inside catch blocks, and Schema.decodeUnknownSync throws.
Step 9c is what makes shape 3 correct. Without it, appending as unknown is a one-token, user-discoverable suppression of the rule's own core case: JSON.parse(s) as Payload would report and JSON.parse(s) as unknown as Payload would not, because step 9d's predicate is unconditionally true whenever the original is any or unknown.
Exclusions, each scoped to the shapes it applies to. The prototype has a fixture for every one, with a positive control beside it.
| # |
exclusion |
shapes |
| 1 |
as const |
all |
| 2 |
JSDoc-reparsed assertions and .js files |
all |
| 3 |
the error type, on target, operand, intermediate and recovered original |
all |
| 4 |
target carries TypeFlagsUnknown |
all |
| 5 |
the node is the operand of an enclosing assertion |
1 |
| 6 |
the node is the object of a property or element access |
all |
| 7 |
target is a type parameter |
2 and 3 |
| 8 |
target is an object type with at least one property, all optional and unknown/any |
2 and 3 |
| 9 |
the checker would have accepted the direct conversion |
2 |
Notes on the non-obvious ones:
- (2)
/** @type {any} */ (x) in a .js file under allowJs is parsed into a real AsExpression (parser/reparser.go:384, :681). as is not JavaScript syntax, so every message would be unfollowable there.
- (6)
(config as any)[key], (caught as ErrnoException | undefined)?.code === "EPERM" and (s as unknown as Bar).b all discard the assertion at the access — note this covers shape 2 as well, because step 8 precedes step 9. The value is never carried onward under the asserted type and the member actually read is still checked. Binding or returning the asserted value does carry the claim and still reports.
- (7) The prototype skips every type parameter, constrained or not — it tests
TypeFlagsTypeParameter and consults no constraint. function f<T>(x: unknown): T { return x as T } is the sanctioned boundary escape hatch, and neither remedy can be performed for a T unknown at the assertion site. Narrowing this to unconstrained parameters is defensible but is a deviation from the measured prototype, and the counts here would not cover it.
- (8) "At least one property" is not incidental:
x as {} satisfies the all-properties-optional test vacuously, and the prototype does report it. An object type with only an index signature (Record<string, unknown>) likewise has no properties and is still reported.
- (9)
declare const x: "a"; x as unknown as "b" must not report: getBaseTypeOfLiteralType("a") is string and isTypeComparableTo("b", string) is true, so TypeScript accepts x as "b" on its own. Omitting the literal-base normalizer turns every string-literal tag comparison into a false positive.
.d.ts and external-library sources need no rule-level test: internal/rulerunner/diagnostics.go:44 returns early for both before dispatching any rule.
Narrowing of unknown is respected for free by taking GetTypeAtLocation(operand), which performs flow analysis. Inside if (typeof x === "string") the operand is string, not unknown. This holds on a guard's positive branch only — instanceof/typeof do not narrow unknown on the else branch — but the else-branch spelling is almost always a member-access probe and is excluded by (6) instead.
Unions and intersections need no special handling: they reach isTypeComparableTo as ordinary targets. Fixtures cover a union target, an intersection target, a class-instance target, an enum member and a unique symbol.
Deliberately out of scope for v1.
- No code fix. No shape has a semantics-preserving rewrite the rule can synthesise: removing a shape-1 assertion changes the expression's type, and shapes 2 and 3 need a guard or decoder the rule cannot author. The generated doc row reads
Fixable | No.
- No non-null
!. Flagging ! itself is a distinct proposal. This is not because KindNonNullExpression is unreachable — it is a transparent wrapper in both walk directions per the definitions above, and (x as any)! as T must report exactly once.
- No
satisfies. KindSatisfiesExpression is a different node kind and cannot reach the walker; it is the checked alternative rather than an instance of the problem.
- No "redundant intermediate assertion" code. When step 9d accepts, the double cast was merely unnecessary — style, not soundness. A rule carries one severity in
diagnosticSeverity, so bundling it would force users to accept both or neither.
- An aliased
any target (type A = any; x as A) is skipped, because isErrorType reports true for an any carrying an alias (checker.go:27031-27035). Safe direction, matches the checker, but a known gap.
Precision, volume, and one recommendation
Scope for every count below: first-party TypeScript under the stated include sets, authored tests included unless noted; dependencies, build output and generated files excluded. Raw counts are raw diagnostic counts, not observed production failures; vetted counts are labelled as such.
I built the rule as a prototype and ran it over a private Effect v4 monorepo — four packages, 2,236 TypeScript files counted over exactly the directories those four tsconfigs include. The run reports 233 diagnostics, of which 41 are outside test files.
I hand-vetted 23 non-test sites by reading the surrounding code. They were not randomly sampled: I worked down the non-test list in file order, preferring the boundary-looking shapes, so this is a judgement sample rather than an estimate of the population. Two of the exclusions above — (6) member-access base and (8) claims nothing — exist because of that vetting and were not designed in advance; they suppressed 6 of the 23.
Of the 17 that still fire: 14 true positives, 3 false positives. That is 14 of 17 hand-vetted sites drawn from 41 non-test sites; the remaining 24 were not vetted. On n=17 the binomial 95% interval runs roughly 57–96%, so please read this as "most of what it reports on application code is real", not as a precision figure. The classifications are my judgement and are not published as an artifact.
Representative true positives, one per mechanism:
| site |
shape |
why it is real |
as never on a SQL row passed to a decoder |
1 |
the row type and the decoder's parameter were never compared |
e.seq as number where seq: unknown into Math.max |
3 |
a string operand silently yields NaN |
catch (error) { error as NodeJS.ErrnoException } |
3 |
canonical boundary shape |
JSON.parse(text) as Partial<HolderRecord> |
3 |
nothing established the shape |
payloadSchema as unknown as Schema.Codec<object, unknown> |
2 |
a decoder runs against the wrong codec |
object literal as unknown as a service interface |
2 |
erases a SocketServerError channel the literal does not produce |
The false-positive class I cannot exclude. All 3 are one shape: the value is validated at runtime, by a check the checker cannot follow.
const o = raw as Record<string, unknown> // reports
for (const key of ["planKey", "chunkId"] as const) {
if (typeof o[key] !== "string" || o[key] === "") return null
}
return { planKey: o["planKey"] as string, chunkId: o["chunkId"] as string } // both report
All three assertions report. The loop validates every key, but the narrowing is invisible to the checker because the key is a loop variable; and Record<string, unknown> has no properties, so exclusion (8) does not cover the first line. At these sites the message's "performs no check at runtime" is factually wrong. Excluding the class would require following loop-based validation. The same shape appears throughout Effect-TS/effect — see the public examples below — so it is not an artifact of one codebase. Whether that is acceptable at off-by-default severity should be a maintainer's decision rather than a surprise.
Shape 1's volume. Over Effect-TS/effect at fd910d1cc6f817ceb677c688d964f4173b3aa0e3, packages/effect/src only (477 files; one package of that monorepo; 13 unrelated pre-existing tsc errors, so the type environment is sound):
| code |
shape |
raw diagnostics |
| TS377136 |
1 — as any / as never |
906 |
| TS377137 |
2 — laundered double assertion |
96 |
| TS377138 |
3 — from any/unknown |
118 |
906 is after exclusion (6). Shape 1 has no predicate and cannot have one, because x as any is always accepted by the checker, so its count is essentially the as any census of the codebase. Shapes 2 and 3 together are 214 — a 4.2:1 ratio. The private monorepo has the opposite profile (30 / 38 / 165 of 233) because it separately forbids as any, so the ratio is a property of the codebase, not of the rule.
Recommendation: split shape 1 into its own rule. diagnosticSeverity is keyed by rule name, so as specified a user who wants shapes 2 and 3 — the two that carry a predicate, and the two that produced every vetted true positive — must accept shape 1 at the same severity, at 4.2:1 on Effect's own source. Three codes buy triage and buy nothing configurable.
If you take the split, these are the consequences, so the choice is not left half-specified:
unsafeAnyAssertion owns shape 1 and code TS377136; unsafeTypeAssertion owns shapes 2 and 3 and codes TS377137/TS377138. Two rule values in rules.All, two _preview.ts fixtures, two generated docs, one changeset.
- Step 5a becomes a cross-rule dependency. The suppression that stops
x as any as T reporting twice lives in shape 1 but exists because shape 2 reports the chain. After a split it must stay unconditional — unsafeAnyAssertion suppresses inside a chain whether or not unsafeTypeAssertion is enabled — or a user who enables only unsafeAnyAssertion gets nothing on x as any as T. That is the one place the split can silently go wrong.
- Exclusion (5) moves to
unsafeAnyAssertion; (7), (8) and (9) stay with unsafeTypeAssertion; (1) to (4) and (6) are duplicated across both.
Where this came up
Public — Effect-TS/effect at fd910d1cc6f817ceb677c688d964f4173b3aa0e3. I found no true positives here, and that is a result worth having.
I read the surrounding code at seven of the 1120 sites, chosen to favour the boundary-looking shapes rather than the obvious library-internal ones. Every one turned out not to be a defect. Two groups, both informative:
Deliberate variance escapes on an internal representation — Graph.ts:580, :584, :699, :742 (GraphImpl<N, E, T> as unknown as Graph<N, E, T>), Result.ts:784, Deferred.ts:120, Context.ts:246, all shape 2. A library crossing its own implementation/interface boundary, where the laundering is the intended mechanism. This is the strongest argument for DefaultSeverity: off that I have.
Validated at a point the checker cannot connect to the assertion — the residual false-positive class above, in public code a maintainer can check without access to a private repo:
packages/effect/src/Channel.ts:783 — buffer as any as Arr.NonEmptyReadonlyArray<A>. It looks like the canonical emptiness hole and is not: line 775 returns early on buffer.length === 0, so the array is genuinely non-empty by 783. I include it because it was my first candidate and I was wrong about it.
packages/effect/src/JsonPatch.ts:409 — { ...(container as Schema.JsonObject) }, where StackEntry.container is declared unknown (:358) but is only ever pushed inside an isJsonObject(cur) branch (:384-386).
packages/effect/src/ErrorReporter.ts:472 and :473 — error[severity] as Severity, first to feed the LogLevel.values.includes(...) check and then to return the value that check just validated.
So: precision is poor on a mature, deliberately-typed library, and 14-of-17 on application code at serialization boundaries. That gap is the rule's actual shape, and it is why this is proposed off by default rather than as a recommended preset member.
Private — an Effect v4 monorepo (a tooling plane and fleet supervisor for a fleet of coding agents). No permalinks; path:line and counts only. Four packages, rule pinned to error, prototype built from origin/main at ae1ed026:
| package |
raw diagnostics |
apps/daemon |
132 |
scripts |
51 |
packages/toolplane |
44 |
packages/assimilator |
6 |
| total |
233 |
By code: 30 shape 1, 38 shape 2, 165 shape 3. Excluding test files: 41 sites — 4 / 5 / 32. Hand-vetted: 14 of 17, per the previous section.
The three sites that most justify the rule:
apps/daemon/src/node-plane/server.ts:58 — an object literal laundered as unknown as a SocketServer service whose run is declared Effect<never, SocketServerError, R> while the literal returns Effect<never, never, never>. An entire error channel is erased, and unsafeEffectTypeAssertion cannot see it because the outer assertion's operand is unknown.
apps/daemon/src/workflow/engine.ts:186 — workflow.payloadSchema as unknown as Schema.Codec<object, unknown>; the checker compared AnyStructSchema with unknown rather than with the codec type the value is then used as.
apps/daemon/src/capture/layer.live.ts:153 — e.seq as number where seq is declared unknown, fed straight into Math.max.
Found by reading checkAssertionDeferred and asking which relation branches accept an assertion without comparing its two types; then prototyped as a tsgo rule and measured against Effect-TS/effect and one private Effect v4 monorepo, with every reported class hand-vetted against the surrounding code.
Implementation notes
Location. internal/rules/unsafe_type_assertion.go, one rule.Rule value appended to rules.All in internal/rules/rules.go next to UnsafeEffectTypeAssertion (:69). Follow the Analyze* split in floating_effect.go so the matcher is testable without the diagnostic layer. The prototype is ~350 lines, about 270 excluding comments and blanks.
Existing helpers that apply. The explicit-stack ForEachChild walker in unsafe_effect_type_assertion.go:69-79 transfers directly. ctx.TypeParser.GetTypeAtLocation gives the flow-narrowed type. scanner.GetErrorRangeForNode for the span, ctx.NewDiagnostic for emission, c.TypeToString for message arguments. ast.IsConstTypeReference, ast.IsInJSFile and ast.NodeFlagsReparsed are already re-exported in shim/ast/shim.go. Note ast.SkipParentheses is not usable as-is: the transparent-wrapper set includes KindNonNullExpression, so the two-kind loop is hand-rolled.
No compiler patch is needed. This is the non-obvious part. Of the seven checker entry points the rule wants, four are already public on the aliased Checker: GetBaseTypeOfLiteralType (exports.go:60), GetWidenedType (:398), GetPropertiesOfType (:139) and GetTypeOfSymbol (:183). The other three — isTypeComparableTo (relater.go:161), getRegularTypeOfObjectLiteral (checker.go:28552) and isErrorType (checker.go:27031) — are unexported, but all three are non-generic methods on *Checker with only exported types in their signatures, so they are reachable by adding them to ExtraMethods.Checker in _tools/gen_shims/config/checker/extra-shim.json, exactly as isTypeAssignableTo already is. pnpm setup-repo then emits the //go:linkname wrappers. The whole change is one line:
- "Checker": ["isTypeAssignableTo", "isArrayType", ...
+ "Checker": ["isTypeAssignableTo", "isTypeComparableTo", "getRegularTypeOfObjectLiteral", "isErrorType", "isArrayType", ...
_patches/typescript/ is untouched, which matters because a patch against the pinned compiler is the expensive kind of change to carry.
Diagnostics. Three entries in internal/diagnostics/effectDiagnosticMessages.json; patch 008 generates the tsdiag.* constants. Codes are hand-assigned literals there, so a merge conflict in that file must be resolved by hand rather than by regenerating — regeneration cannot resolve two branches that picked the same integer, and codes are published surface (generated doc, oxlint preset, users' ignore comments), so renumbering after publication is not an option. I hit exactly this conflict integrating alongside two other in-flight rules and resolved it by hand.
Test fixture layout. testdata/tests/effect-v4/unsafeTypeAssertion.ts and effect-v3/unsafeTypeAssertion.ts, plus the unsafeTypeAssertion_preview.ts that rules_json_test.go:345-354 requires for the generated doc. Reference baselines under testdata/baselines/reference/effect-v{3,4}/ are created automatically on the first run (internal/effecttest/baseline.go:595-613) and then reviewed. Then UPDATE_RULE_DOCS=1, UPDATE_README=1, UPDATE_METADATA_JSON=1, UPDATE_OXLINT_PRESETS=1, UPDATE_OXLINT_SCHEMA=1, UPDATE_TSCONFIG_SCHEMA=1, and a .changeset/*.md with "@effect/tsgo": minor.
The prototype's fixture carries 25 diagnostics and an equal weight of negatives — as const, target unknown, a comparable direct assertion, satisfies, non-null, narrowed unknown, a redundant-but-sound double assertion, the literal-to-literal case, a type-parameter target, both probe shapes and both claims-nothing shapes — each with a positive control beside it so an exclusion cannot silently over-fire. It type-checks with zero error TS of its own, which is what makes the baseline meaningful.
Measured cost. darwin arm64, Go 1.26.8, CGO_ENABLED=0.
pnpm build for the rule: 6 s incremental, 18 s after a merge.
- Diagnostics wall-clock over the four private-monorepo packages, same binary and same configs, rule at
error vs off, three alternating repetitions: off 6292 / 9166 / 6632 ms, error 6787 / 8652 / 6500 ms. The difference is below run-to-run variance on this machine — in one of the three pairs the rule-off run was the slower one. I can say the rule costs nothing measurable at this corpus size; I cannot give you a number. Mechanically it is one pass over assertion nodes, and only step 9d calls the relation at all.
pnpm lint exit 0 (0 issues. / deadcode: clean), pnpm check exit 0, pnpm test at the platform baseline (Go: 20 packages ok, 0 FAIL; vitest: 3 failed | 122 passed, all three the pre-existing /var vs /private/var realpath failures in _packages/tsgo/test/experimental-oxlint.test.ts on macOS).
Proposed rule name
unsafeTypeAssertion.
If shape 1 is split out per the recommendation above: unsafeTypeAssertion for shapes 2 and 3, unsafeAnyAssertion for shape 1.
Problem
A type assertion is gated by exactly one check,
checkAssertionDeferred(tsc/internal/checker/checker.go:12542-12556at the pinned compiler revision879f9867ac455404e75759dd1739281cf6aa7f85), quoted in full:So
expr as Tis accepted iffwhere
normalized(t) = getRegularTypeOfObjectLiteral(getBaseTypeOfLiteralType(t)). The first disjunct is evaluated first and short-circuits.isTypeComparableTo(s, t)isisTypeRelatedTo(s, t, comparableRelation)(relater.go:161-163). For the comparable relation,isTypeRelatedToprobes both argument orders before any structural work (relater.go:180):and
isSimpleTypeRelatedToshort-circuits in its first two branches (relater.go:205-213):That is the whole bug surface.
anyas the relation's target,neveras its source, andunknownas its target each returntruebefore the two real types are ever compared. Because line 180 probes both orders, a top or bottom type on either side of the assertion reaches one of those branches:x as anyxist&Any, assertion target in the relation's target positionx as neverxiss&Never, assertion target in the relation's source positionx as unknownxist&Unknown, assertion target in the relation's target positionx as unknown as Tunknown:180putsunknownin the target position, sot&UnknownunknownwithTx as any as Tanyt&AnyanywithTJSON.parse(s) as Tanyt&AnyanywithTIn every row after the first three, the user's real type and the target they claimed never met. TypeScript reports nothing, and no other diagnostic surfaces the result. TS2352 is the diagnostic that would otherwise have fired, and its own remediation text — "If this was intentional, convert the expression to
unknownfirst" — tells users how to reach the state this rule is about.What breaks at runtime. The asserted type is trusted by everything downstream while nothing established it. Three vetted examples from the private codebase in Where this came up: a
Schema.Codec<A, I>laundered into aSchema.Codec<object, unknown>, so a decoder runs against the wrong codec; an object literal laundered into a service interface whoserunis declaredEffect<never, SocketServerError, R>while the literal returnsEffect<never, never, never>, silently erasing an error channel the rest of the program never handles; and a value declaredunknownasserted tonumberand fed intoMath.max, where a string operand yieldsNaN.How this differs from
unsafeEffectTypeAssertion. That rule requires both the operand and the target to parse as anEffect,StreamorLayer, then compares theEandRchannels (internal/rules/unsafe_effect_type_assertion.go:96-120). It is about narrowing within Effect's channels on a conversion the checker already accepted; this proposal is about the conversion never being checked at all, in any type. To be precise about the evidence:unsafeEffectTypeAssertionproduced zero diagnostics over both measured corpora, so the corpora cannot demonstrate non-overlap. The argument for non-overlap is structural — that rule inspects only Effect types, and this one never inspects Effect types at all.A separate, smaller bug the measurement exposed. Inserting
as unknownsilencesunsafeEffectTypeAssertionentirely. With both rules aterroragainsteffect@4.0.0-beta.107, over a file whose line 5 isprogram as Effect.Effect<string, never, never>and whose line 8 writes the same narrowing asprogram as unknown as Effect.Effect<string, never, never>, the whole output is:Line 8 is reported by neither rule:
unsafeEffectTypeAssertioncomputes the outer assertion's operand type, getsunknown, fails to parse it as an Effect, and returns. The rule proposed here is also silent, correctly by its own predicate — the checker accepts the direct form, so nothing was laundered. The fix belongs in the existing rule: strip a chain of assertions throughany/unknown/neverbefore parsing the operand. Not part of this proposal; recorded because the measurement found it.Bad — compiles cleanly, the rule should flag this
Verified: these declarations and assertions, with the explanatory comments removed, compile at exit 0 under
"strict": truewithtypescript@7.0.2patched by@effect/tsgo@0.45.0, againsteffect@4.0.0-beta.107, withunsafeTypeAssertionalready pinned to"error"(inert, because the rule does not exist). Against a build carrying the rule the same file exits 2 and reports all five sites across all three codes.Good
Zero
unsafeTypeAssertiondiagnostics on this file under the prototype.Proposed rule behavior
What to build. One rule,
unsafeTypeAssertion, covering all three shapes under three codes. Every number in this issue was produced by a prototype of exactly that. The Precision, volume, and one recommendation section recommends splitting shape 1 into a second rule and states precisely what would change if you take that option; if you do, build the split version, but read the numbers here as describing the combined rule.Group
correctness,DefaultSeverity: off,SupportedEffect: ["v3", "v4"]— matching the two existing soundness rules. The rule is Effect-agnostic and gates on neither the Effect version nor the presence of aneffectimport. Every message ends witheffect(unsafeTypeAssertion)and is declared"category": "Warning"ininternal/diagnostics/effectDiagnosticMessages.json. The codes below are the ones the prototype used and are free as ofmain(current maximum377133).Definitions used throughout.
{KindParenthesizedExpression, KindNonNullExpression}, stripped identically when walking down to an operand and when walking up to an enclosing node.ast.SkipParenthesesis not sufficient — it handles only the first kind. Every up-walk and down-walk in this rule must strip the same set; that single invariant is what keeps chains from reporting twice.TypeFlagsAny,TypeFlagsUnknownorTypeFlagsNever.c.errorTypeisnewIntrinsicType(TypeFlagsAny, "error")(checker.go:1023) and so satisfiesTypeFlagsAny.Decision procedure, applied to each
KindAsExpression/KindTypeAssertionExpressionnode.operandisnode.Expression();targetTypeis the type of the assertion node;operandTypeisGetTypeAtLocation(operand), which is flow-narrowed.consttype reference (ast.IsConstTypeReference) → skip.ast.IsInJSFile(node)ortypeNode.Flags & ast.NodeFlagsReparsed != 0→ skip.targetTypeis the error type → skip.targetTypecarriesTypeFlagsUnknown→ skip (widening tounknownis sound).targetTypecarriesTypeFlagsAnyorTypeFlagsNever:a. if
node, after stripping transparent wrappers upward, is the operand of an enclosing assertion → skip (the outer node reports the chain);b. if
nodeis the object of a property or element access → skip;c. otherwise → report shape 1.
targetTypecarriesTypeFlagsTypeParameter→ skip.targetTypeis an object type with at least one property, all optional and typedunknownorany→ skip.nodeis the object of a property or element access → skip.inner=operandwith transparent wrappers stripped. Ifinneris an assertion whose own type is top/bottom and not the error type:a. walk down from
innerthrough assertion links whose own type is top/bottom, stopping at the first node that is either not an assertion or an assertion whose type is concrete. Call itoriginal;b.
originalTypeis the error type → skip;c.
originalTypecarriesTypeFlagsAnyorTypeFlagsUnknown→ report shape 3 (this is what stopsas unknownbeing a suppression);d. the checker would have accepted
original as targetdirectly → skip;e. otherwise → report shape 2.
operandTypeis the error type → skip.operandTypecarriesTypeFlagsAnyorTypeFlagsUnknown→ report shape 3.Shape 1 —
x as any,<any>x,x as never. CodeTS377136.Message:
Asserting to `{0}` disables type checking for this expression. Remove the assertion, or narrow the value with a type guard.—{0}is the target type.Step 5a is not merely deduplication: it also silences.
declare const x: string; x as any as stringreports nothing at all, because shape 1 is suppressed on the inner node and step 9d accepts the outer. That is correct — the double cast is redundant rather than unsound — but it is worth knowing.x as neveris worth calling out: the checker accepts it for every operand via thes&TypeFlagsNeverbranch (relater.go:208), so it is a finding no existing diagnostic duplicates. Verified rather than assumed — the fixture containingvalue as nevertype-checks with zeroerror TSof its own.Shape 2 — a double assertion through a top or bottom type. Code
TS377137.Message:
This assertion routes through `{0}`, so the checker compared `{1}` with `{0}` rather than with `{2}`. A type guard or `Schema.is` checks the value instead.—{0}is the intermediate (top/bottom) type,{1}the original operand's type,{2}the final target. Declarative on purpose: TS2352 tells users to insertas unknown, so the message states what happened rather than implying the user did something unsanctioned.Step 9d re-applies
checkAssertionDeferred's predicate verbatim, with the original operand's type substituted for the laundered one:This is the rule's one formal guarantee, and it is worth stating exactly: shape 2 never reports a conversion the checker would have accepted with the intermediate assertion removed. Shapes 1 and 3 carry no such guarantee; they are unconditional positional findings whose justification is policy, not the checker's verdict.
Argument order matters.
isTypeComparableTois asymmetric:relater.go:180guards the reversed probe withtarget.flags&TypeFlagsNever == 0, soisTypeComparableTo(X, never)is false whileisTypeComparableTo(never, X)is true. Inverting the disjuncts changes the verdict forneverparticipants and for every structural comparison. (It changes nothing forunknown, where both orders succeed.)Step 9a's stopping rule is load-bearing. In
(x as Foo) as any as Bartheas Foolink was already compared by the checker, soFoo— nottypeof x— is what the outer assertion launders. Stopping atxinstead makes this a false negative whenevertypeof xisunknown, because the predicate then succeeds ont&TypeFlagsUnknown. Measured: the prototype reportscompared `Foo` with `any` rather than with `Bar`.The converse is worth stating, because it is the other half of step 5a: a chain is deduplicated only across its top/bottom links, not across its concrete ones. Each link whose own target is concrete is judged on its own merits, so one source line can legitimately carry more than one diagnostic. Measured on
declare const x: unknown; (x as Foo) as any as Bar, the prototype emits two: shape 2 on the outer node (compared `Foo` with `any` rather than with `Bar`) and shape 3 on the innerx as Foo(Asserting `unknown` to `Foo`). Both are real and neither subsumes the other.Shape 3 —
x as TwherexisanyorunknownandTis concrete. CodeTS377138.Message:
Asserting `{0}` to `{1}` performs no check at runtime. Narrow the value with a type guard, or decode it with a `Schema` decoder.—{0}is the operand's type,{1}the target. The remedy names a type guard first and a decoder second deliberately: this fires insidecatchblocks, andSchema.decodeUnknownSyncthrows.Step 9c is what makes shape 3 correct. Without it, appending
as unknownis a one-token, user-discoverable suppression of the rule's own core case:JSON.parse(s) as Payloadwould report andJSON.parse(s) as unknown as Payloadwould not, because step 9d's predicate is unconditionally true whenever the original isanyorunknown.Exclusions, each scoped to the shapes it applies to. The prototype has a fixture for every one, with a positive control beside it.
as const.jsfilesTypeFlagsUnknownunknown/anyNotes on the non-obvious ones:
/** @type {any} */ (x)in a.jsfile underallowJsis parsed into a realAsExpression(parser/reparser.go:384,:681).asis not JavaScript syntax, so every message would be unfollowable there.(config as any)[key],(caught as ErrnoException | undefined)?.code === "EPERM"and(s as unknown as Bar).ball discard the assertion at the access — note this covers shape 2 as well, because step 8 precedes step 9. The value is never carried onward under the asserted type and the member actually read is still checked. Binding or returning the asserted value does carry the claim and still reports.TypeFlagsTypeParameterand consults no constraint.function f<T>(x: unknown): T { return x as T }is the sanctioned boundary escape hatch, and neither remedy can be performed for aTunknown at the assertion site. Narrowing this to unconstrained parameters is defensible but is a deviation from the measured prototype, and the counts here would not cover it.x as {}satisfies the all-properties-optional test vacuously, and the prototype does report it. An object type with only an index signature (Record<string, unknown>) likewise has no properties and is still reported.declare const x: "a"; x as unknown as "b"must not report:getBaseTypeOfLiteralType("a")isstringandisTypeComparableTo("b", string)is true, so TypeScript acceptsx as "b"on its own. Omitting the literal-base normalizer turns every string-literal tag comparison into a false positive..d.tsand external-library sources need no rule-level test:internal/rulerunner/diagnostics.go:44returns early for both before dispatching any rule.Narrowing of
unknownis respected for free by takingGetTypeAtLocation(operand), which performs flow analysis. Insideif (typeof x === "string")the operand isstring, notunknown. This holds on a guard's positive branch only —instanceof/typeofdo not narrowunknownon theelsebranch — but theelse-branch spelling is almost always a member-access probe and is excluded by (6) instead.Unions and intersections need no special handling: they reach
isTypeComparableToas ordinary targets. Fixtures cover a union target, an intersection target, a class-instance target, an enum member and aunique symbol.Deliberately out of scope for v1.
Fixable | No.!. Flagging!itself is a distinct proposal. This is not becauseKindNonNullExpressionis unreachable — it is a transparent wrapper in both walk directions per the definitions above, and(x as any)! as Tmust report exactly once.satisfies.KindSatisfiesExpressionis a different node kind and cannot reach the walker; it is the checked alternative rather than an instance of the problem.diagnosticSeverity, so bundling it would force users to accept both or neither.anytarget (type A = any; x as A) is skipped, becauseisErrorTypereports true for ananycarrying an alias (checker.go:27031-27035). Safe direction, matches the checker, but a known gap.Precision, volume, and one recommendation
Scope for every count below: first-party TypeScript under the stated
includesets, authored tests included unless noted; dependencies, build output and generated files excluded. Raw counts are raw diagnostic counts, not observed production failures; vetted counts are labelled as such.I built the rule as a prototype and ran it over a private Effect v4 monorepo — four packages, 2,236 TypeScript files counted over exactly the directories those four tsconfigs
include. The run reports 233 diagnostics, of which 41 are outside test files.I hand-vetted 23 non-test sites by reading the surrounding code. They were not randomly sampled: I worked down the non-test list in file order, preferring the boundary-looking shapes, so this is a judgement sample rather than an estimate of the population. Two of the exclusions above — (6) member-access base and (8) claims nothing — exist because of that vetting and were not designed in advance; they suppressed 6 of the 23.
Of the 17 that still fire: 14 true positives, 3 false positives. That is 14 of 17 hand-vetted sites drawn from 41 non-test sites; the remaining 24 were not vetted. On n=17 the binomial 95% interval runs roughly 57–96%, so please read this as "most of what it reports on application code is real", not as a precision figure. The classifications are my judgement and are not published as an artifact.
Representative true positives, one per mechanism:
as neveron a SQL row passed to a decodere.seq as numberwhereseq: unknownintoMath.maxNaNcatch (error) { error as NodeJS.ErrnoException }JSON.parse(text) as Partial<HolderRecord>payloadSchema as unknown as Schema.Codec<object, unknown>as unknown asa service interfaceSocketServerErrorchannel the literal does not produceThe false-positive class I cannot exclude. All 3 are one shape: the value is validated at runtime, by a check the checker cannot follow.
All three assertions report. The loop validates every key, but the narrowing is invisible to the checker because the key is a loop variable; and
Record<string, unknown>has no properties, so exclusion (8) does not cover the first line. At these sites the message's "performs no check at runtime" is factually wrong. Excluding the class would require following loop-based validation. The same shape appears throughoutEffect-TS/effect— see the public examples below — so it is not an artifact of one codebase. Whether that is acceptable atoff-by-default severity should be a maintainer's decision rather than a surprise.Shape 1's volume. Over
Effect-TS/effectatfd910d1cc6f817ceb677c688d964f4173b3aa0e3,packages/effect/srconly (477 files; one package of that monorepo; 13 unrelated pre-existingtscerrors, so the type environment is sound):as any/as neverany/unknown906 is after exclusion (6). Shape 1 has no predicate and cannot have one, because
x as anyis always accepted by the checker, so its count is essentially theas anycensus of the codebase. Shapes 2 and 3 together are 214 — a 4.2:1 ratio. The private monorepo has the opposite profile (30 / 38 / 165 of 233) because it separately forbidsas any, so the ratio is a property of the codebase, not of the rule.Recommendation: split shape 1 into its own rule.
diagnosticSeverityis keyed by rule name, so as specified a user who wants shapes 2 and 3 — the two that carry a predicate, and the two that produced every vetted true positive — must accept shape 1 at the same severity, at 4.2:1 on Effect's own source. Three codes buy triage and buy nothing configurable.If you take the split, these are the consequences, so the choice is not left half-specified:
unsafeAnyAssertionowns shape 1 and codeTS377136;unsafeTypeAssertionowns shapes 2 and 3 and codesTS377137/TS377138. Two rule values inrules.All, two_preview.tsfixtures, two generated docs, one changeset.x as any as Treporting twice lives in shape 1 but exists because shape 2 reports the chain. After a split it must stay unconditional —unsafeAnyAssertionsuppresses inside a chain whether or notunsafeTypeAssertionis enabled — or a user who enables onlyunsafeAnyAssertiongets nothing onx as any as T. That is the one place the split can silently go wrong.unsafeAnyAssertion; (7), (8) and (9) stay withunsafeTypeAssertion; (1) to (4) and (6) are duplicated across both.Where this came up
Public —
Effect-TS/effectatfd910d1cc6f817ceb677c688d964f4173b3aa0e3. I found no true positives here, and that is a result worth having.I read the surrounding code at seven of the 1120 sites, chosen to favour the boundary-looking shapes rather than the obvious library-internal ones. Every one turned out not to be a defect. Two groups, both informative:
Deliberate variance escapes on an internal representation —
Graph.ts:580,:584,:699,:742(GraphImpl<N, E, T> as unknown as Graph<N, E, T>),Result.ts:784,Deferred.ts:120,Context.ts:246, all shape 2. A library crossing its own implementation/interface boundary, where the laundering is the intended mechanism. This is the strongest argument forDefaultSeverity: offthat I have.Validated at a point the checker cannot connect to the assertion — the residual false-positive class above, in public code a maintainer can check without access to a private repo:
packages/effect/src/Channel.ts:783—buffer as any as Arr.NonEmptyReadonlyArray<A>. It looks like the canonical emptiness hole and is not: line 775 returns early onbuffer.length === 0, so the array is genuinely non-empty by 783. I include it because it was my first candidate and I was wrong about it.packages/effect/src/JsonPatch.ts:409—{ ...(container as Schema.JsonObject) }, whereStackEntry.containeris declaredunknown(:358) but is only ever pushed inside anisJsonObject(cur)branch (:384-386).packages/effect/src/ErrorReporter.ts:472and:473—error[severity] as Severity, first to feed theLogLevel.values.includes(...)check and then to return the value that check just validated.So: precision is poor on a mature, deliberately-typed library, and 14-of-17 on application code at serialization boundaries. That gap is the rule's actual shape, and it is why this is proposed
offby default rather than as arecommendedpreset member.Private — an Effect v4 monorepo (a tooling plane and fleet supervisor for a fleet of coding agents). No permalinks;
path:lineand counts only. Four packages, rule pinned toerror, prototype built fromorigin/mainatae1ed026:apps/daemonscriptspackages/toolplanepackages/assimilatorBy code: 30 shape 1, 38 shape 2, 165 shape 3. Excluding test files: 41 sites — 4 / 5 / 32. Hand-vetted: 14 of 17, per the previous section.
The three sites that most justify the rule:
apps/daemon/src/node-plane/server.ts:58— an object literal launderedas unknown asaSocketServerservice whoserunis declaredEffect<never, SocketServerError, R>while the literal returnsEffect<never, never, never>. An entire error channel is erased, andunsafeEffectTypeAssertioncannot see it because the outer assertion's operand isunknown.apps/daemon/src/workflow/engine.ts:186—workflow.payloadSchema as unknown as Schema.Codec<object, unknown>; the checker comparedAnyStructSchemawithunknownrather than with the codec type the value is then used as.apps/daemon/src/capture/layer.live.ts:153—e.seq as numberwhereseqis declaredunknown, fed straight intoMath.max.Found by reading
checkAssertionDeferredand asking which relation branches accept an assertion without comparing its two types; then prototyped as a tsgo rule and measured againstEffect-TS/effectand one private Effect v4 monorepo, with every reported class hand-vetted against the surrounding code.Implementation notes
Location.
internal/rules/unsafe_type_assertion.go, onerule.Rulevalue appended torules.Allininternal/rules/rules.gonext toUnsafeEffectTypeAssertion(:69). Follow theAnalyze*split infloating_effect.goso the matcher is testable without the diagnostic layer. The prototype is ~350 lines, about 270 excluding comments and blanks.Existing helpers that apply. The explicit-stack
ForEachChildwalker inunsafe_effect_type_assertion.go:69-79transfers directly.ctx.TypeParser.GetTypeAtLocationgives the flow-narrowed type.scanner.GetErrorRangeForNodefor the span,ctx.NewDiagnosticfor emission,c.TypeToStringfor message arguments.ast.IsConstTypeReference,ast.IsInJSFileandast.NodeFlagsReparsedare already re-exported inshim/ast/shim.go. Noteast.SkipParenthesesis not usable as-is: the transparent-wrapper set includesKindNonNullExpression, so the two-kind loop is hand-rolled.No compiler patch is needed. This is the non-obvious part. Of the seven checker entry points the rule wants, four are already public on the aliased
Checker:GetBaseTypeOfLiteralType(exports.go:60),GetWidenedType(:398),GetPropertiesOfType(:139) andGetTypeOfSymbol(:183). The other three —isTypeComparableTo(relater.go:161),getRegularTypeOfObjectLiteral(checker.go:28552) andisErrorType(checker.go:27031) — are unexported, but all three are non-generic methods on*Checkerwith only exported types in their signatures, so they are reachable by adding them toExtraMethods.Checkerin_tools/gen_shims/config/checker/extra-shim.json, exactly asisTypeAssignableToalready is.pnpm setup-repothen emits the//go:linknamewrappers. The whole change is one line:_patches/typescript/is untouched, which matters because a patch against the pinned compiler is the expensive kind of change to carry.Diagnostics. Three entries in
internal/diagnostics/effectDiagnosticMessages.json; patch 008 generates thetsdiag.*constants. Codes are hand-assigned literals there, so a merge conflict in that file must be resolved by hand rather than by regenerating — regeneration cannot resolve two branches that picked the same integer, and codes are published surface (generated doc, oxlint preset, users' ignore comments), so renumbering after publication is not an option. I hit exactly this conflict integrating alongside two other in-flight rules and resolved it by hand.Test fixture layout.
testdata/tests/effect-v4/unsafeTypeAssertion.tsandeffect-v3/unsafeTypeAssertion.ts, plus theunsafeTypeAssertion_preview.tsthatrules_json_test.go:345-354requires for the generated doc. Reference baselines undertestdata/baselines/reference/effect-v{3,4}/are created automatically on the first run (internal/effecttest/baseline.go:595-613) and then reviewed. ThenUPDATE_RULE_DOCS=1,UPDATE_README=1,UPDATE_METADATA_JSON=1,UPDATE_OXLINT_PRESETS=1,UPDATE_OXLINT_SCHEMA=1,UPDATE_TSCONFIG_SCHEMA=1, and a.changeset/*.mdwith"@effect/tsgo": minor.The prototype's fixture carries 25 diagnostics and an equal weight of negatives —
as const, targetunknown, a comparable direct assertion,satisfies, non-null, narrowedunknown, a redundant-but-sound double assertion, the literal-to-literal case, a type-parameter target, both probe shapes and both claims-nothing shapes — each with a positive control beside it so an exclusion cannot silently over-fire. It type-checks with zeroerror TSof its own, which is what makes the baseline meaningful.Measured cost. darwin arm64, Go 1.26.8,
CGO_ENABLED=0.pnpm buildfor the rule: 6 s incremental, 18 s after a merge.errorvsoff, three alternating repetitions: off 6292 / 9166 / 6632 ms, error 6787 / 8652 / 6500 ms. The difference is below run-to-run variance on this machine — in one of the three pairs the rule-off run was the slower one. I can say the rule costs nothing measurable at this corpus size; I cannot give you a number. Mechanically it is one pass over assertion nodes, and only step 9d calls the relation at all.pnpm lintexit 0 (0 issues./deadcode: clean),pnpm checkexit 0,pnpm testat the platform baseline (Go: 20 packagesok, 0 FAIL; vitest: 3 failed | 122 passed, all three the pre-existing/varvs/private/varrealpath failures in_packages/tsgo/test/experimental-oxlint.test.tson macOS).Proposed rule name
unsafeTypeAssertion.If shape 1 is split out per the recommendation above:
unsafeTypeAssertionfor shapes 2 and 3,unsafeAnyAssertionfor shape 1.