Skip to content

Rule proposal: assertions routed through any, unknown or never are accepted without comparing the two real types #749

Description

@jfspencer

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.

  1. Type node is a const type reference (ast.IsConstTypeReference) → skip.
  2. ast.IsInJSFile(node) or typeNode.Flags & ast.NodeFlagsReparsed != 0skip.
  3. targetType is the error type → skip.
  4. targetType carries TypeFlagsUnknownskip (widening to unknown is sound).
  5. 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.
  6. targetType carries TypeFlagsTypeParameterskip.
  7. targetType is an object type with at least one property, all optional and typed unknown or anyskip.
  8. node is the object of a property or element access → skip.
  9. 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 TypeFlagsUnknownreport 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.
  10. operandType is the error type → skip.
  11. operandType carries TypeFlagsAny or TypeFlagsUnknownreport shape 3.
  12. 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 unconditionalunsafeAnyAssertion 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 representationGraph.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:783buffer 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 :473error[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:186workflow.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:153e.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions