LogoSkills

illegal-states-unrepresentable

반복 버그를 타입으로 차단하는 설계 기법 — 식별자 공간 분리, 인덱스 base 타입화, 파라미터 객체, 파생 상태 금지, 동시성 기본값 명시, 게이트 목적 분리. 새 식별자·인덱스·필터 파라미터를 추가하거나 위젯 로컬 상태를 도입하거나, 기존 게이트/불리언 값을 위젯 Key·렌더러 선택에 재사용할 때 사용합니다.

Illegal States Unrepresentable#

한마디로#

같은 실수가 반복되지 않도록, 애초에 그 실수를 저지를 수 없는 모양으로 코드를 짜는 방법을 모아둔 안내서입니다.

콘센트를 생각하면 쉽습니다. 220V 플러그를 110V 구멍에 억지로 꽂아 기기를 태우는 사고를 막는 방법은 두 가지입니다. 하나는 "조심하세요"라고 써 붙이는 것, 다른 하나는 구멍 모양을 아예 다르게 만들어 물리적으로 안 들어가게 하는 것입니다. 이 스킬은 두 번째 방법을 코드에 적용합니다.

주의문은 언젠가 안 읽히지만, 안 맞는 모양은 언제나 안 맞습니다.

무엇을·언제#

  • 무엇을 해 주나요
    • 사람이 "규칙을 잘 지켜야 하는" 자리를 찾아, 지키지 않으면 컴퓨터가 즉시 오류를 내는 구조로 바꾸는 방법을 알려줍니다.
    • 특히 "한 곳만 빠뜨렸는데 아무도 몰랐다" 유형의 반복 버그를 대상으로 합니다.
  • 언제 작동하나요
    • 새로운 종류의 ID(사용자 번호, 주문 번호 등)를 코드에 도입할 때
    • 목록의 페이지 번호나 순서 번호를 다룰 때
    • 함수에 검색 조건(필터) 항목을 추가할 때
    • 화면이 스스로 기억하는 값을 새로 만들 때
    • 비동기 처리(서버 요청 등)를 하는 이벤트를 새로 등록할 때

핵심 용어#

용어쉬운 설명
타입(Type)값의 종류. "이 자리에는 이 종류만 들어갈 수 있다"는 약속
컴파일 오류프로그램을 만들기도 전에 컴퓨터가 잡아내는 오류 (실행 전에 발견되므로 가장 안전)
관례(Convention)문서나 구두로 정한 규칙. 안 지켜도 컴퓨터는 모름
extension type성능 손해 없이 숫자·문자에 이름표를 붙이는 Dart 기능
파생 상태이미 있는 값에서 계산해 낼 수 있는 값 (따로 저장하면 두 값이 어긋날 수 있음)
SSOT (단일 진실 원천)어떤 정보의 정답이 오직 한 곳에만 있는 상태
레이스 컨디션두 작업의 도착 순서가 뒤바뀌어 엉뚱한 결과가 남는 현상

Triggers#

  • When introducing a new identifier kind (user id, order id, external UUID)
  • When handling page numbers, offsets, or any index with a base convention
  • When adding a filter/option parameter to an existing query function
  • When adding local widget state (useState, setState, controllers)
  • When registering a new on<Event>() handler that performs async work
  • When a fix PR would be described as "the sibling call site already does this correctly"
  • When a boolean/gate computed for one purpose (pointer routing, a permission check, ...) is about to feed a widget Key, a renderer/branch choice, or any other unrelated decision

Actions#

  1. Identify the safety rule that currently exists only as a convention
  2. Choose the technique below that moves it into the type system
  3. Apply it at the definition site so every call site inherits it
  4. Verify: deleting the safety line should now produce a compile error, not working code

The test that decides whether a rule needs a type#

Ask one question about the rule you are relying on:

If a future contributor omits this, what happens?

AnswerVerdict
Compile errorFine as-is
Lint error in CIAcceptable — record where the lint is configured
Test failureAcceptable if the test is not itself optional
Nothing — it compiles and ships Needs one of the techniques below

The last row is where repeat bugs come from. Three symptoms confirm it:

  1. Omission compiles — the missing line looks like a line that was simply not needed.
  2. Sibling drift — a correct call site sits right next to the broken one, so review reads the neighbourhood and assumes consistency.
  3. Audits repeat — the only remaining tool is a codebase-wide sweep, and the same sweep runs again months later.

1. Separate identifier spaces into distinct types#

Independent ID sequences that are all int (or all String) are freely assignable to each other. Nothing marks the mistake, and the two values agree often enough in testing to look correct.

WRONG — two unrelated sequences compared as plain int:

// auth user-info id space
final uid = int.parse(session.userIdentifier);

// app user id space — an entirely different sequence
if (participant.userId == uid) {
  // authorized
}

Since the sequences are independent, this passes whenever the two numbers happen to coincide — which, for any table with rows at low ids, is a real occurrence rather than a theoretical one.

Rightextension type gives each space a name at zero runtime cost:

extension type AppUserId(int value) {}
extension type AuthUserInfoId(int value) {}
extension type IdpUserUuid(String value) {}

/// The only producer of AppUserId. Every consumer must go through it.
Future<AppUserId> getCurrentAppUserId(Session session) async { ... }

final AppUserId uid = await getCurrentAppUserId(session);
if (participant.userId == uid) { ... }   // AuthUserInfoId here → compile error

Apply it when a project has more than one notion of "user", "account", or "tenant" — for example a framework-owned auth row, an application-owned profile row, and an external identity-provider UUID.

Real case: kobic #7741 — an ownership check compared an auth-space id with an app-space id, so any logged-in user whose auth id coincided with a participant's app id bypassed the check. Six further bugs share the root cause: #8931 (FormatException when the identifier was a UUID rather than an int), #8602 (authUserId overwritten with NULL), #9076.


2. Encode the index base in the type#

page is 1-based, offset is 0-based, and both are int. Every conversion site re-derives the formula, and one of them eventually skips it.

WRONG — a 1-based page passed where a 0-based row offset is expected:

final request = MessagesRequest(limit: 20, offset: page);
// page=1 → OFFSET 1 → the newest row is silently dropped on every page

Right:

extension type PageNumber(int value) {
  RowOffset toOffset(int limit) => RowOffset((value - 1) * limit);
}
extension type RowOffset(int value) {}

final request = MessagesRequest(limit: 20, offset: page.toOffset(20));

Real case: kobic #9152 — four media UseCases assigned a 1-based page straight into a 0-based offset. The fix PR notes that the sibling GetChatMessagesPaginatedUsecase had used the correct formula all along, which is exactly why review did not catch the four copies.


3. Make the parameter set one required object#

Optional parameters added one at a time drift apart: a filter reaches one code path but not its twin, and the symptom is "the filter does nothing" rather than an error.

WRONG — every call site independently remembers to forward every filter:

Future<Result> call({int? categoryId, BuyerType? buyerType, DateRange? range});
Future<Result> fetchOnce({int? categoryId, DateRange? range});   // buyerType silently missing

Right — one required object; adding a field breaks every call site that must adapt:

@immutable
final class SalesFilter extends Equatable {
  const SalesFilter({this.categoryId, this.buyerType, this.range});

  final int? categoryId;
  final BuyerType? buyerType;
  final DateRange? range;

  @override
  List<Object?> get props => [categoryId, buyerType, range];
}

Future<Result> call(SalesFilter filter);
Future<Result> fetchOnce(SalesFilter filter);

The object also doubles as a cache key component, which prevents the related failure where two different queries share one cached response.

Real case: kobic #9077 (buyerType reached the streaming path but not fetchOnce()), #7738 (categoryId missing on one "see more" route while the sibling route passed it), #4bd305 (backend never accepted the days parameter at all).


4. Derive state; do not store it#

A local copy of a value that is already computable from a source of truth can disagree with it. The disagreement appears only on entry paths nobody tested.

WRONG — a hardcoded initial value that the real source will later contradict:

final selectedIndex = useState<int>(0);   // sidebar highlight
// the actual visible branch is decided by the navigation shell elsewhere

Right — compute it from the single source:

final selectedIndex = resolveUIIndexForBranch(navigationShell.currentIndex);

The same rule covers parallel arrays and their index constants. If a route list and an index-constant class must stay 1:1, generate one from the other rather than maintaining both:

// ✅ one source — the enum. The branch list is generated, so it cannot drift.
enum ConsoleBranch { dashboard, books, members, /* ... */ }

final branches = ConsoleBranch.values.map(_buildBranch).toList();

For form drafts, derive dirtiness from value equality rather than hand-written field comparison — a hand-written comparison omits exactly one field eventually:

// ❌ WRONG — a per-field check that will miss a field
bool get isDirty => draft.nickname != original.nickname || draft.email != original.email;

// ✅ Right — Equatable/value equality covers every field automatically
bool get isDirty => draft != original;

Real case: kobic #9092 — the sidebar highlight and the visible screen disagreed. The same defect had been fixed once before (#6925), but that fix corrected one entry path while the duplicated state survived, so it resurfaced through a different path. Siblings: #7749, #9154 (a form field excluded from the dirty check), #9027, #9084, #9075.


5. Choose the concurrency mode explicitly#

on<Event>() defaults to concurrent(). For anything that awaits a network call this is almost always the wrong default: responses can arrive out of order, and a slow earlier response overwrites a fresh later one.

WRONG — an awaiting handler with no transformer, so overlapping runs race:

on<_PollTicked>(_onPollTicked);   // stale 'running' overwrites the arrived 'completed'

Right — state the intent:

on<_PollTicked>(_onPollTicked, transformer: restartable());
TransformerBehaviourUse for
restartable() Cancel the in-flight run, start the new one Reloads, search-as-you-type, polling, SWR stream subscriptions — the common default
droppable() Ignore new events while one is running Submit buttons, one-shot bootstraps
sequential() Queue and run in order Transactional mutations whose order matters
concurrent() Run all simultaneously Genuinely independent work — state it explicitly so reviewers know it was chosen

Handlers that only call emit() synchronously need no transformer.

Real case: kobic UB-225 — a polling handler with no transformer let a delayed response overwrite a completed state, leaving a permanent loading screen. Siblings: a highlight loader missing restartable() while _loadBookmarks in the same file had it, and a presigned-URL upload whose debounce was documented in a comment but absent from the registration.


6. Make the guard produce a value#

When a safety check is a standalone statement, deleting it leaves working code. When it produces a value the rest of the function needs, deleting it is a compile error.

WRONG — the guard is an optional statement:

Future<List<Member>> listMembers(Session session) async {
  await requireAdmin(session);   // remove this line: still compiles, still ships
  return MemberService.list(session);
}

Right — the guard is the only producer of a token the body requires:

/// Private constructor — `requireAdmin` is the sole producer.
final class AdminSession {
  const AdminSession._(this.session);
  final Session session;
}

Future<AdminSession> requireAdmin(Session session) async { ... }

Future<List<Member>> listMembers(Session session) async {
  final admin = await requireAdmin(session);
  return MemberService.list(admin);   // omitting the guard → compile error
}

This matters most where a framework auto-exposes public methods as remote endpoints: the method becomes callable the moment it is written, while the guard is a separate line inside the body.

A secondary lesson from the same case: do not let several enforcement mechanisms coexist without a rule for which to use. When declarative scopes, mixins, explicit helper calls, and manual checks are all in play, "no guard at all" becomes visually indistinguishable from the other four during review.

Real case: kobic Epic #7415 / #8510 — 31 authorization fix commits across six weeks including two confirmed PII exposures. The closing commit is titled "권한 가드 일괄 감사 마무리" (bulk audit finish), meaning the sweep ran for the entire epic.


7. Don't let one gate answer two unrelated questions#

A boolean computed to answer question A ("should this pointer event be routed as a pen stroke?") gets wired into question B ("which widget subtree renders this page?") because, in every case anyone tested, the two answers happened to agree. They diverge the day a new tool is added that needs "yes" for A but "no" for B, and whichever call site nobody audited breaks — often only in the builds nobody was debugging in.

This is the same shape as technique 1 (two ID spaces sharing one int), but the collision is between two decisions sharing one bool instead of two identifiers sharing one primitive type.

WRONG — a pointer-routing gate also drives a widget Key and renderer selection:

final scribbleActive = resolveScribbleActive(
  isScribbleEnabled: enabled,
  isScribbleMode: mode,
  isStickyNoteToolMode: stickyMode, // belongs to the pointer-routing question
);

final pageViewerKey = scribbleActive ? generateScribbleKey(...) : null;
// Picking the sticky-note tool flips `scribbleActive` to false, which
// changes `pageViewerKey`, which makes Flutter tear down and remount the
// page under a *different* renderer backed by a second, unrelated
// PdfViewerController. The page renders nothing.

Nothing here looks wrong locally — scribbleActive reads like a single well-named source of truth, which is exactly why review does not flag the second call site as a new, unrelated question riding along on the first.

Right — one function per question; the narrower one cannot even see the input that belongs to the other:

/// Pointer-routing only. Do not use for renderer/Key selection — see
/// [resolveScribbleRendererActive].
bool resolveScribbleActive({
  required bool isScribbleEnabled,
  required bool isScribbleMode,
  required bool isStickyNoteToolMode,
}) => !isStickyNoteToolMode && (isScribbleEnabled || isScribbleMode);

/// Renderer selection only. Deliberately has no `isStickyNoteToolMode`
/// parameter, so a tool-mode concern cannot be wired back into it without
/// editing this signature.
bool resolveScribbleRendererActive({
  required bool isScribbleEnabled,
  required bool isScribbleMode,
}) => isScribbleEnabled || isScribbleMode;

The renderer-selection function's parameter list is the enforcement: it structurally cannot read the sticky-note flag, so reintroducing the coupling requires changing the function itself rather than adding one more line at a call site. That is this skill's opening test — if a future contributor omits the safety rule, what happens? — applied to a function signature instead of a type.

Real case: kobic #10316 (fix: #10318) — resolveScribbleActive, a pointer-routing gate introduced for #9885, also fed PdfPageViewer's Key and isScribbleEnabled. Selecting the sticky-note tool flipped the gate, which swapped the PDF page's renderer to a widget backed by a second PdfViewerController, leaving the book content blank. It reproduced only in profile/release — a debug-only !semantics.parentDataDirty assertion forced per-frame rebuilds that happened to mask it in debug builds — and because the toolbar persists lastTool, every user who had ever selected sticky note hit a blank viewer on every subsequent open until they picked a different tool. See [cc-flutter:performance-testing] — bugs that reproduce only in profile/release need profile/release verification; a debug-only pass is not sufficient evidence of "fixed."


When a type is not available#

Some rules cannot be typed — a third-party API's default value, a required call to an external service. Fall back in this order:

  1. Wrap and ban the original. Provide a safe wrapper, then make the raw API unreachable (deprecation plus a lint or CI grep guard). This is the only mitigation with a proven zero-recurrence record in kobic: after FlushMenu wrapped the raw menu widget, direct raw-menu usage went to 0 and the nested-panel bug that had recurred four times stopped.
  2. CI guard script. A diff-scoped grep with an inline exception comment (// <rule>-exception: <reason>), starting as a warning and promoted to blocking once the false-positive rate is known.
  3. Review checklist item. Weakest — use only when 1 and 2 are impossible. See [cc-quality:code-review-checklist] §9.

A wrapper without step 1's ban is not a mitigation; it is a suggestion. Third-party breaking changes make this concrete: when a file-picker library flipped a boolean default in a minor release, the fix PR patched the one reported call site while 17 of 18 call sites remained unguarded — because nothing prevented calling the raw API.


Checklist#

  • New identifier kind has its own extension type, not bare int/String
  • New index/page value carries its base in the type
  • New filter parameter is part of a required parameter object, not an added optional arg
  • New local widget state is not derivable from an existing source of truth
  • Parallel arrays/constants are generated from one source, not maintained in pairs
  • Dirty checks use value equality, not per-field comparison
  • New awaiting on<Event>() specifies transformer:
  • New guard produces a value its caller needs, rather than standing alone
  • A gate/derived boolean that feeds a widget Key or a renderer/branch choice answers only that question — it is not reused from (or fed into) a gate scoped to an unrelated concern
  • A bug that reproduces only in profile/release has been re-verified in profile/release after the fix, not just in debug
  • Where a type is impossible: wrapper + ban, or a CI guard — not just a doc line

관련 스킬#

  • [cc-quality:code-review-checklist] — §9 Structural Failure Modes: review-time counterpart of this skill
  • [cc-dev:discovery-audit] — triage→verify workflow for sweeping an existing occurrence of one of these classes
  • [cc-flutter:swr-pattern] — §16 covers the error-propagation contract these techniques rely on
  • [cc-flutter:flutter-patterns] — layer structure the examples assume
  • [cc-flutter:performance-testing] — profile/release verification for defects that debug builds mask