타입 승격이 실패하는 이유 정리하기

타입 승격이 실패하는 이유 정리하기

if (x != null)로 널 체크를 했는데도 컴파일러가 "이 변수는 여전히 nullable이야" 하고 에러를 내는 경험, 한 번쯤 있으시죠? 그건 바로 **타입 승격(type promotion)**이 어떤 이유로 실패했기 때문이에요. 이 글에서는 타입 승격이 실패하는 여러 이유와 그 해결 방법을 하나씩 살펴볼게요.

출처: Fixing type promotion failures

본문

타입 승격은 흐름 분석(flow analysis)이 타입 T인 변수가 실제로는 T의 하위 타입 S의 객체를 가리킨다고 건전하게(soundly) 확인했을 때, 그 변수를 S로 올려 주는 것을 말해요. 특별한 경우로, 변수가 nullable 타입인데 흐름 분석이 null이 아니라고 확인하면, 그 변수를 대응하는 non-nullable 타입으로 승격해 주기도 해요.

그런데 많은 상황이 타입의 건전성을 약화시켜서 타입 승격을 실패하게 만들어요. 이 페이지에서는 타입 승격이 실패하는 이유를 나열하고, 각각을 고치는 팁을 함께 정리했어요.

흐름 분석과 타입 승격에 대해 더 배우고 싶다면 Understanding null safety 페이지를 확인해 보세요.

필드 승격에 대해 지원되지 않는 언어 버전 (Unsupported language version for field promotion)

원인: 필드를 승격하려고 하는데, 필드 승격은 언어 버전과 관련이 있어서 코드가 3.2 이전 언어 버전으로 설정돼 있었어요. 이미 SDK 버전이 Dart 3.2 이상이라도, 코드가 명시적으로 더 이른 언어 버전을 대상으로 하고 있을 수 있어요. 이런 일은 다음 중 하나 때문에 생겨요.

  • pubspec.yaml의 SDK 제약 조건이 3.2 미만의 하한(lower bound)을 선언하거나,
  • 파일 맨 위에 // @dart=version 주석이 있는데 그 version이 3.2보다 낮은 경우.

예시:

// @dart=3.1

class C {
  final int? _i;
  C(this._i);

  void f() {
    if (_i != null) {
      int i = _i; // ERROR
    }
  }
}

메시지: '_i' refers to a field. It couldn't be promoted because field promotion is only available in Dart 3.2 and above.

해결: 라이브러리가 3.2보다 이른 언어 버전을 쓰지 않도록 해요. 파일 맨 위에 낡은 // @dart=version 주석이 있는지, 또는 pubspec.yaml에 낡은 SDK 제약 하한이 있는지 확인해 보세요.

지역 변수만 승격 가능 (Dart 3.2 이전) — Only local variables can be promoted

원인: 프로퍼티를 승격하려고 하는데, Dart 3.2 이전 버전에서는 지역 변수만 승격할 수 있어요. 그리고 여러분이 3.2 이전 버전을 쓰고 있는 거죠.

예시:

class C {
  int? i;

  void f() {
    if (i == null) return;
    print(i.isEven); // ERROR
  }
}

메시지: 'i' refers to a property so it couldn't be promoted.

해결: Dart 3.1 이하를 쓰고 있다면 3.2 이상으로 업그레이드해요. 더 오래된 버전을 계속 써야 한다면 아래의 "기타 원인과 해결 방법(Other causes and workarounds)"을 읽어 보세요.

기타 원인과 해결 방법 (Other causes and workarounds)

이 페이지의 나머지 예시들은 버전 불일치와는 관계없는 승격 실패 이유를 다뤄요. 필드와 지역 변수 실패 모두에 대해 예시와 해결 방법을 함께 보여 드릴게요.

일반적으로 승격 실패를 고치는 방법은 다음 중 하나 또는 그 이상이에요.

  • 프로퍼티의 값을, 필요한 non-nullable 타입을 가진 지역 변수에 할당하기.
  • 명시적 null 검사(예: i == null) 추가하기.
  • 표현식이 null일 수 없다고 확신하면 !as중복 검사로 사용하기.

다음은 i의 값을 담는 지역 변수(이름을 i로 지을 수 있어요)를 만드는 예시예요.

class C {
  int? i;

  void f() {
    final i = this.i;
    if (i == null) return;
    print(i.isEven);
  }
}

이 예시에는 인스턴스 필드가 나오지만, 인스턴스 게터, 정적 필드나 게터, 최상위 변수나 게터, this를 사용해도 똑같이 적용돼요.

그리고 i!를 사용하는 예시는 이렇습니다.

print(i!.isEven);

this를 승격할 수 없음 (Can't promote this)

원인: this를 승격하려고 하는데, this에 대한 타입 승격은 아직 지원되지 않아요. 자주 마주치는 this 승격 시나리오 중 하나는 확장 메서드(extension method)를 작성할 때예요. 확장 메서드의 on 타입이 nullable 타입이라면, this가 null인지 확인하는 null 체크를 하고 싶겠죠.

예시:

extension on int? {
  int get valueOrZero {
    return this == null ? 0 : this; // ERROR
  }
}

메시지: `this` can't be promoted.

해결: this의 값을 담을 지역 변수를 만든 다음 null 체크를 해요.

extension on int? {
  int get valueOrZero {
    final self = this;
    return self == null ? 0 : self;
  }
}

private 필드만 승격 가능 (Only private fields can be promoted)

원인: 필드를 승격하려고 하는데, 그 필드가 private이 아니에요. 프로그램의 다른 라이브러리가 public 필드를 게터(getter)로 덮어쓸(override) 가능성이 있어요. 게터는 안정적인 값을 반환하지 않을 수도 있고, 컴파일러가 다른 라이브러리가 뭘 하는지 알 수 없기 때문에, non-private 필드는 승격될 수 없어요.

예시:

class Example {
  final int? value;
  Example(this.value);
}

void test(Example x) {
  if (x.value != null) {
    print(x.value + 1); // ERROR
  }
}

메시지: 'value' refers to a public property so it couldn't be promoted.

해결: 필드를 private으로 만들면 컴파일러가 외부 라이브러리가 그 값을 덮어쓸 수 없다고 확신할 수 있어서, 승격해도 안전해져요.

class Example {
  final int? _value;
  Example(this._value);
}

void test(Example x) {
  if (x._value != null) {
    print(x._value + 1);
  }
}

final 필드만 승격 가능 (Only final fields can be promoted)

원인: 필드를 승격하려고 하는데, 그 필드가 final이 아니에요. 컴파일러 입장에서 non-final 필드는 검사 시점과 사용 시점 사이에 언제든 수정될 수 있으므로, non-final nullable 타입을 non-nullable 타입으로 승격하는 것은 안전하지 않아요.

예시:

class Example {
  int? _mutablePrivateField;
  Example(this._mutablePrivateField);

  void f() {
    if (_mutablePrivateField != null) {
      int i = _mutablePrivateField; // ERROR
    }
  }
}

메시지: '_mutablePrivateField' refers to a non-final field so it couldn't be promoted.

해결: 필드를 final로 만들어요.

class Example {
  final int? _immutablePrivateField;
  Example(this._immutablePrivateField);

  void f() {
    if (_immutablePrivateField != null) {
      int i = _immutablePrivateField; // OK
    }
  }
}

게터는 승격할 수 없음 (Getters can't be promoted)

원인: 게터를 승격하려고 하는데, 승격할 수 있는 건 인스턴스 필드뿐이고 인스턴스 게터는 아니에요. 컴파일러는 게터가 매번 같은 결과를 반환한다는 걸 보장할 방법이 없어요. 안정성을 확인할 수 없으니 게터는 승격하기에 안전하지 않아요.

예시:

import 'dart:math';

abstract class Example {
  int? get _value => Random().nextBool() ? 123 : null;
}

void f(Example x) {
  if (x._value != null) {
    print(x._value.isEven); // ERROR
  }
}

메시지: '_value' refers to a getter so it couldn't be promoted.

해결: 게터를 지역 변수에 할당해요.

import 'dart:math';

abstract class Example {
  int? get _value => Random().nextBool() ? 123 : null;
}

void f(Example x) {
  final value = x._value;
  if (value != null) {
    print(value.isEven); // OK
  }
}

외부 필드는 승격할 수 없음 (External fields can't be promoted)

원인: 필드를 승격하려고 하는데, 그 필드가 external로 표시돼 있어요. 외부 필드는 본질적으로 외부 게터라서 승격되지 않아요. 구현이 Dart 밖의 코드이기 때문에, 컴파일러가 외부 필드가 호출될 때마다 같은 값을 반환할 거라고 보장할 수 없어요.

예시:

class Example {
  external final int? _externalField;

  void f() {
    if (_externalField != null) {
      print(_externalField.isEven); // ERROR
    }
  }
}

메시지: '_externalField' refers to an external field so it couldn't be promoted.

해결: 외부 필드의 값을 지역 변수에 할당해요.

class Example {
  external final int? _externalField;

  void f() {
    final i = _externalField;
    if (i != null) {
      print(i.isEven); // OK
    }
  }
}

같은 라이브러리 안의 게터와 충돌 (Conflict with getter elsewhere in library)

원인: 필드를 승격하려고 하는데, 같은 라이브러리의 다른 클래스에 같은 이름의 구체적인 게터(concrete getter)가 있어요.

예시:

import 'dart:math';

class Example {
  final int? _overridden;
  Example(this._overridden);
}

class Override implements Example {
  @override
  int? get _overridden => Random().nextBool() ? 1 : null;
}

void testParity(Example x) {
  if (x._overridden != null) {
    print(x._overridden.isEven); // ERROR
  }
}

메시지: '_overridden' couldn't be promoted because there is a conflicting getter in class 'Override'.

해결: 게터와 필드가 연관되어 있고 이름을 공유해야 한다면(위 예시처럼 하나가 다른 하나를 오버라이드하는 경우), 값을 지역 변수에 할당해 타입 승격을 활성화할 수 있어요.

import 'dart:math';

class Example {
  final int? _overridden;
  Example(this._overridden);
}

class Override implements Example {
  @override
  int? get _overridden => Random().nextBool() ? 1 : null;
}

void testParity(Example x) {
  final i = x._overridden;
  if (i != null) {
    print(i.isEven); // OK
  }
}

관련 없는 클래스에 대한 참고 (Note about unrelated classes): 위 예시에서는 필드 _overridden을 승격하는 게 왜 안전하지 않은지 명확해요. 필드와 게터 사이에 오버라이드 관계가 있기 때문이죠. 하지만 클래스들이 서로 관련이 없어도 충돌하는 게터는 필드 승격을 막아요. 예를 들면:

import 'dart:math';

class Example {
  final int? _i;
  Example(this._i);
}

class Unrelated {
  int? get _i => Random().nextBool() ? 1 : null;
}

void f(Example x) {
  if (x._i != null) {
    int i = x._i; // ERROR
  }
}

다른 라이브러리가 두 관련 없는 클래스를 같은 클래스 계층으로 합치는 클래스를 가질 수도 있어서, 이 경우 함수 f 안의 x._i 참조가 Unrelated._i로 디스패치될 수 있어요.

class Surprise extends Unrelated implements Example {}

void main() {
  f(Surprise());
}

해결: 필드와 충돌하는 대상이 진짜로 관련이 없다면, 서로 다른 이름을 줘서 문제를 우회할 수 있어요.

class Example {
  final int? _i;
  Example(this._i);
}

class Unrelated {
  int? get _j => Random().nextBool() ? 1 : null;
}

void f(Example x) {
  if (x._i != null) {
    int i = x._i; // OK
  }
}

같은 라이브러리 안의 승격 불가 필드와 충돌 (Conflict with non-promotable field elsewhere in library)

원인: 필드를 승격하려고 하는데, 같은 라이브러리의 다른 클래스에 같은 이름의 승격 불가능한 필드(이 페이지에 나열된 다른 이유 중 하나 때문에)가 있어요.

예시:

class Example {
  final int? _overridden;
  Example(this._overridden);
}

class Override implements Example {
  @override
  int? _overridden;
}

void f(Example x) {
  if (x._overridden != null) {
    print(x._overridden.isEven); // ERROR
  }
}

이 예시가 실패하는 이유는 런타임에 x가 실제로는 Override의 인스턴스일 수 있어서, 승격이 건전하지 않기 때문이에요.

메시지: 'overridden' couldn't be promoted because there is a conflicting non-promotable field in class 'Override'.

해결: 필드들이 실제로 연관되어 있고 이름을 공유해야 한다면, 값을 final 지역 변수에 할당해서 타입 승격을 활성화할 수 있어요.

class Example {
  final int? _overridden;
  Example(this._overridden);
}

class Override implements Example {
  @override
  int? _overridden;
}

void f(Example x) {
  final i = x._overridden;
  if (i != null) {
    print(i.isEven); // OK
  }
}

필드들이 관련이 없다면, 둘 중 하나를 리네이밍해서 충돌하지 않게 해요. "관련 없는 클래스에 대한 참고"를 읽어 보세요.

암시적 noSuchMethod 포워더와의 충돌 (Conflict with implicit noSuchMethod forwarder)

원인: private이고 final인 필드를 승격하려고 하는데, 같은 라이브러리의 다른 클래스에 필드와 같은 이름의 암시적 noSuchMethod 포워더가 있어요. noSuchMethod가 호출 때마다 안정적인 값을 반환한다는 보장이 없기 때문에 건전하지 않아요.

예시:

import 'package:mockito/mockito.dart';

class Example {
  final int? _i;
  Example(this._i);
}

class MockExample extends Mock implements Example {}

void f(Example x) {
  if (x._i != null) {
    int i = x._i; // ERROR
  }
}

이 예시에서 _iMockExample 안에 컴파일러가 생성한 건전하지 않은 암시적 noSuchMethod 포워더(역시 이름이 _i)로 해석될 수 있기 때문에 승격할 수 없어요. 컴파일러는 MockExample이 선언에서 Example을 implements하면서 _i의 게터를 지원하겠다고 약속했지만 그 약속을 지키지 않았기 때문에, 이 암시적 _i 구현을 만들어요. 그래서 정의되지 않은 게터 구현을 MocknoSuchMethod 정의가 처리하고, 이것이 같은 이름의 암시적 noSuchMethod 포워더를 만드는 거예요. 이 실패는 관련 없는 클래스의 필드 사이에서도 발생할 수 있어요.

메시지: '_i' couldn't be promoted because there is a conflicting noSuchMethod forwarder in class 'MockExample'.

해결: 해당 게터를 정의해 줘서 noSuchMethod가 그 구현을 암시적으로 처리하지 않게 해요.

import 'package:mockito/mockito.dart';

class Example {
  final int? _i;
  Example(this._i);
}

class MockExample extends Mock implements Example {
  @override
  late final int? _i;
}

void f(Example x) {
  if (x._i != null) {
    int i = x._i; // OK
  }
}

게터를 late로 선언하는 건 mock을 일반적으로 쓰는 방식과 일관되게 하기 위한 거예요. mock이 관련되지 않은 시나리오에서 이 타입 승격 실패를 해결하기 위해 게터를 late로 선언할 필요는 없어요.

승격 후에 쓰여질 가능성 (Possibly written after promotion)

원인: 승격된 이후에 쓰여질 수 있는 변수를 승격하려고 해요.

예시:

void f(bool b, int? i, int? j) {
  if (i == null) return;
  if (b) {
    i = j; // (1)
  }
  if (!b) {
    print(i.isEven); // (2) ERROR
  }
}

해결: 이 예시에서 흐름 분석이 (1)에 도달하면 i를 non-nullable int에서 다시 nullable int?로 강등(demote)해요. 사람이라면 (2)의 접근이 안전하다는 걸 알 수 있어요. (1)과 (2)를 모두 포함하는 코드 경로가 없기 때문이죠. 하지만 흐름 분석은 서로 다른 if 문의 조건 사이의 상관관계를 추적하지 않기 때문에 그걸 알아채지 못해요. 두 if 문을 합치면 문제를 고칠 수 있어요.

void f(bool b, int? i, int? j) {
  if (i == null) return;
  if (b) {
    i = j;
  } else {
    print(i.isEven);
  }
}

루프가 없는 직선적 제어 흐름(straight-line control flow)의 경우, 흐름 분석은 강등 여부를 결정할 때 할당의 오른쪽을 고려해요. 그 결과, 이 코드를 고치는 또 다른 방법은 j의 타입을 int로 바꾸는 거예요.

void f(bool b, int? i, int j) {
  if (i == null) return;
  if (b) {
    i = j;
  }
  if (!b) {
    print(i.isEven);
  }
}

이전 루프 반복에서 쓰여질 가능성 (Possibly written in a previous loop iteration)

원인: 이전 루프 반복에서 쓰여졌을 수 있는 것을 승격하려고 하기 때문에 승격이 무효화됐어요.

예시:

void f(Link? p) {
  if (p != null) return;
  while (true) { // (1)
    print(p.value); // (2) ERROR
    var next = p.next;
    if (next == null) break;
    p = next; // (3)
  }
}

흐름 분석이 (1)에 도달하면 앞을 내다보고 (3)에서 p에 대한 쓰기를 봐요. 하지만 앞을 내다보는 중이기 때문에 아직 할당 오른쪽의 타입을 파악하지 못해서, 승격을 유지해도 안전한지 알 수 없어요. 안전을 위해 승격을 무효화하는 거예요.

해결: null 체크를 루프 맨 위로 옮기면 문제를 고칠 수 있어요.

void f(Link? p) {
  while (p != null) {
    print(p.value);
    p = p.next;
  }
}

이 상황은 switch 문에서도 생길 수 있어요. case 블록에 라벨이 있으면 라벨이 붙은 switch 문으로 루프를 구성할 수 있기 때문이에요.

void f(int i, int? j, int? k) {
  if (j == null) return;
  switch (i) {
    label:
    case 0:
      print(j.isEven); // ERROR
      j = k;
      continue label;
  }
}

역시 null 체크를 루프 맨 위로 옮기면 문제를 고칠 수 있어요.

void f(int i, int? j, int? k) {
  switch (i) {
    label:
    case 0:
      if (j == null) return;
      print(j.isEven);
      j = k;
      continue label;
  }
}

try에서 쓰여질 수 있는 후의 catch (In catch after possible write in try)

원인: 변수가 try 블록에서 쓰여졌을 수 있는데, 실행이 이제 catch 블록에 있어요.

예시:

void f(int? i, int? j) {
  if (i == null) return;
  try {
    i = j; // (1)
    // ... 추가 코드 ...
    if (i == null) return; // (2)
    // ... 추가 코드 ...
  } catch (e) {
    print(i.isEven); // (3) ERROR
  }
}

이 경우 흐름 분석은 i.isEven(3)을 안전하다고 보지 않아요. try 블록 안에서 예외가 언제 발생했을지 알 방법이 없으므로, 보수적으로 (1)과 (2) 사이, 즉 i가 잠재적으로 null이었을 때 발생했을 수 있다고 가정하기 때문이에요. 비슷한 상황이 tryfinally 블록 사이, catchfinally 블록 사이에서도 발생할 수 있어요. 구현의 역사적 산출물 때문에 이런 try/catch/finally 상황은 루프에서처럼 할당의 오른쪽을 고려하지 않아요.

해결: 문제를 고치려면 catch 블록이 try 블록 안에서 변경되는 변수의 상태에 대한 가정에 의존하지 않도록 해요. 예외는 try 블록 중 언제든, 아마 i가 null일 때도 발생할 수 있다는 걸 기억하세요. 가장 안전한 해결은 catch 블록 안에 null 체크를 추가하는 거예요.

try {
  // ...
} catch (e) {
  if (i != null) {
    print(i.isEven); // (3) 위 줄의 null 체크 덕분에 OK
  } else {
    // i가 null인 경우를 처리
  }
}

또는 i가 null인 동안에는 예외가 발생할 수 없다고 확신한다면, 그냥 ! 연산자를 써도 돼요.

try {
  // ...
} catch (e) {
  print(i!.isEven); // (3) `!` 덕분에 OK
}

하위 타입 불일치 (Subtype mismatch)

원인: 변수의 현재 승격된 타입의 하위 타입이 아닌 타입(또는 승격 시도 시점에 하위 타입이 아니었던 타입)으로 승격하려고 해요.

예시:

void f(Object o) {
  if (o is Comparable /* (1) */) {
    if (o is Pattern /* (2) */) {
      print(o.matchAsPrefix('foo')); // (3) ERROR
    }
  }
}

이 예시에서 o는 (1)에서 Comparable로 승격되지만, (2)에서는 Pattern으로 승격되지 않아요. PatternComparable의 하위 타입이 아니기 때문이에요. (만약 승격된다면 Comparable의 메서드를 쓸 수 없게 되기 때문이라는 게 근거예요.) PatternComparable의 하위 타입이 아니라고 해서 (3)의 코드가 죽은 코드(dead code)라는 뜻은 아니에요. oComparablePattern을 모두 구현하는 String 같은 타입을 가질 수도 있으니까요.

해결: 가능한 해결책 중 하나는 새 지역 변수를 만들어서 원래 변수는 Comparable로, 새 변수는 Pattern으로 승격되게 하는 거예요.

void f(Object o) {
  if (o is Comparable /* (1) */) {
    Object o2 = o;
    if (o2 is Pattern /* (2) */) {
      print(
        o2.matchAsPrefix('foo'),
      ); // (3) OK; o2가 `Pattern`으로 승격됨
    }
  }
}

하지만 나중에 코드를 수정하는 누군가가 Object o2var o2로 바꾸고 싶어 할 수도 있어요. 그렇게 바꾸면 o2Comparable 타입이 되어서, 객체가 Pattern으로 승격될 수 없는 문제가 다시 생겨요. 중복 타입 검사가 더 나은 해결책일 수 있어요.

void f(Object o) {
  if (o is Comparable /* (1) */) {
    if (o is Pattern /* (2) */) {
      print((o as Pattern).matchAsPrefix('foo')); // (3) OK
    }
  }
}

때로는 더 정밀한 타입을 사용하면 해결되는 경우도 있어요. 3번째 줄이 문자열에만 관심이 있다면, 타입 검사에 String을 쓸 수 있어요. StringComparable의 하위 타입이므로 승격이 동작해요.

void f(Object o) {
  if (o is Comparable /* (1) */) {
    if (o is String /* (2) */) {
      print(o.matchAsPrefix('foo')); // (3) OK
    }
  }
}

지역 함수에 의한 쓰기 캡처 (Write captured by a local function)

원인: 변수가 지역 함수나 함수 표현식에 의해 쓰기 캡처(write-captured)됐어요.

예시:

void f(int? i, int? j) {
  var foo = () {
    i = j;
  };
  // ... foo 사용 ...
  if (i == null) return; // (1)
  // ... 추가 코드 ...
  print(i.isEven); // (2) ERROR
}

흐름 분석은 foo의 정의에 도달하는 순간 그게 언제든 호출될 수 있다고 판단하므로, 더 이상 i를 전혀 승격하는 게 안전하지 않다고 봐요. 루프와 마찬가지로 이 강등은 할당 오른쪽의 타입과 무관하게 일어나요.

해결: 때로는 승격이 쓰기 캡처 앞에 오도록 로직을 재구성할 수 있어요.

void f(int? i, int? j) {
  if (i == null) return; // (1)
  // ... 추가 코드 ...
  print(i.isEven); // (2) OK
  var foo = () {
    i = j;
  };
  // ... foo 사용 ...
}

또 다른 방법은 지역 변수를 만들어서 쓰기 캡처되지 않게 하는 거예요.

void f(int? i, int? j) {
  var foo = () {
    i = j;
  };
  // ... foo 사용 ...
  var i2 = i;
  if (i2 == null) return; // (1)
  // ... 추가 코드 ...
  print(i2.isEven); // (2) `i2`가 쓰기 캡처되지 않았으므로 OK
}

또는 중복 검사를 할 수도 있어요.

void f(int? i, int? j) {
  var foo = () {
    i = j;
  };
  // ... foo 사용 ...
  if (i == null) return; // (1)
  // ... 추가 코드 ...
  print(i!.isEven); // (2) `!` 검사 덕분에 OK
}

현재 클로저나 함수 표현식 밖에서 쓰여짐 (Written outside of the current closure or function expression)

원인: 변수가 클로저나 함수 표현식 밖에서 쓰여지는데, 타입 승격 위치는 클로저나 함수 표현식 안이에요.

예시:

void f(int? i, int? j) {
  if (i == null) return;
  var foo = () {
    print(i.isEven); // (1) ERROR
  };
  i = j; // (2)
}

흐름 분석은 foo가 언제 호출될지 결정할 방법이 없다고 판단해요. (2)의 할당 이후에 호출될 수도 있으니, 승격이 더 이상 유효하지 않을 수 있는 거예요. 루프와 마찬가지로 이 강등은 할당 오른쪽의 타입과 무관하게 일어나요.

해결: 지역 변수를 만드는 게 해결책이에요.

void f(int? i, int? j) {
  if (i == null) return;
  var i2 = i;
  var foo = () {
    print(i2.isEven); // (1) `i2`는 나중에 바뀌지 않으므로 OK
  };
  i = j; // (2)
}

예시: 특히 까다로운 경우는 이렇게 생겼어요.

void f(int? i) {
  i ??= 0;
  var foo = () {
    print(i.isEven); // ERROR
  };
}

이 경우 사람이라면 승격이 안전하다는 걸 알 수 있어요. i에 대한 유일한 쓰기가 non-null 값이고 foo가 만들어지기 전에 일어나니까요. 하지만 흐름 분석은 그렇게 똑똑하지 않아요.

해결: 역시 지역 변수를 만드는 게 해결책이에요.

void f(int? i) {
  var j = i ?? 0;
  var foo = () {
    print(j.isEven); // OK
  };
}

이 해결책이 동작하는 이유는 j가 초기값(i ?? 0) 덕분에 non-nullable 타입(int)으로 추론되기 때문이에요. j가 non-nullable 타입이라서, 나중에 할당되든 안 되든 j는 결코 null 값을 가질 수 없어요.

현재 클로저나 함수 표현식 밖에서의 쓰기 캡처 (Write captured outside of the current closure or function expression)

원인: 승격하려는 변수가 클로저나 함수 표현식 밖에서 쓰기 캡처됐는데, 이 변수의 이번 사용은 승격을 시도하는 클로저나 함수 표현식 안에 있어요.

예시:

void f(int? i, int? j) {
  var foo = () {
    if (i == null) return;
    print(i.isEven); // ERROR
  };
  var bar = () {
    i = j;
  };
}

흐름 분석은 foobar가 어떤 순서로 실행될지 알 방법이 없다고 판단해요. 사실 barfoo를 실행하는 도중에도 실행될 수 있어요(foobar를 호출하는 무언가를 호출하기 때문에). 그래서 foo 안에서 i를 승격하는 건 안전하지 않아요.

해결: 가장 좋은 해결책은 지역 변수를 만드는 거예요.

void f(int? i, int? j) {
  var foo = () {
    var i2 = i;
    if (i2 == null) return;
    print(i2.isEven); // i2는 이 클로저에 지역적이므로 OK
  };
  var bar = () {
    i = j;
  };
}

await나 yield를 가로질러 승격이 사라짐 (Promotion lost across an await or yield)

원인: 실행이 await 표현식이나 yield 문을 가로질러 중단(suspend)되면 흐름 분석이 타입 승격을 버려요. 이 승격 실패가 발생하려면 다음 조건이 모두 충족돼야 해요.

  • 함수나 메서드가 지역 변수나 파라미터에 쓰기를 포함한다.
  • 그 함수가 변수를 승격하는 내부 함수나 클로저를 포함한다.
  • 내부 함수가 awaityield로 스스로를 중단시켜, 외부 함수가 계속 실행되게 한다.
  • 내부 함수가 중단 후에 지역 변수를 다시 사용하려 한다.

이전 SDK 릴리스에서는 중단 후에 지역 변수를 사용해도 승격된 타입이 유지됐어요. 하지만 그 동작은 건전하지 않았어요. 내부 함수가 중단돼 있는 동안 외부 함수가 계속 실행되어 변수의 값을 수정할 수 있기 때문이에요. Dart 3.13부터는 건전성을 보장하기 위해, 내부 함수가 중단되면 흐름 분석이 승격을 버려요.

예시: 컴파일러는 승격을 유지하는 게 명백히 안전한 경우에도 중단 지점을 가로질러 승격을 버려요.

import 'dart:async';

Future<void> example(String? extraInfo) async {
  extraInfo ??= 'No extra info'; // (1)
  unawaited(() async {
    log('Doing some asynchronous task...');
    log(extraInfo!);
    await longTask(); // (2)
    log('Done!');
    log(extraInfo); // (3) ERROR
  }());
}

Future<void> longTask() async {
  await Future<void>.delayed(const Duration(seconds: 10));
}

void log(String s) {
  print(s);
}

이 예시에서 (1)에서 extraInfo에 값을 할당한다는 것은 그게 실질적으로 final이 아니라는 뜻이에요. 흐름 분석은 클로저가 캡처한 수정된 변수는 (2)에서 클로저가 중단된 동안 바뀔 수 있다고 보수적으로 가정해요. (1)의 할당이 언제나 클로저가 실행되기 전에 일어난다 하더라도 말이죠. 건전성을 보장하기 위해 흐름 분석은 승격을 버려요. 그 결과 (3)에서 extraInfoString?으로 강등되고, 이는 log(String s)와 호환되지 않아 컴파일 타임 에러가 나요.

메시지: 이 상황이 발생하면 컴파일러나 분석기가 에러와 컨텍스트 메시지를 내보내요.

example.dart:10:9: Error: The argument type 'String?' can't be assigned to the parameter type 'String'.
  log(extraInfo);
        ^
example.dart:8:5: Context: Variable 'extraInfo' could not be promoted due to an 'await' or 'yield'.
Try checking the type of the variable after the 'await' or 'yield'. See http://dart.dev/go/non-promo-suspension
  await longTask();
      ^

해결: 이 컴파일 타임 에러를 고치려면, 중단 지점 이후에 변수의 non-null 타입을 다시 검증하거나, 명시적 null 검사를 추가하거나, non-null 단언 !를 사용하면 돼요.

import 'dart:async';

Future<void> example(String? extraInfo) async {
  extraInfo ??= 'No extra info';
  unawaited(() async {
    log('Doing some asynchronous task...');
    log(extraInfo!);
    await longTask();
    log('Done!');
    log(extraInfo!);
  }());
}

Future<void> longTask() async {
  await Future<void>.delayed(const Duration(seconds: 10));
}

void log(String s) {
  print(s);
}

또는 중단 전에 변수를 final 지역 변수에 할당하거나, await 표현식 뒤에 extraInfo != null을 다시 확인하는 방식도 있어요.

더 알아보기