Modern Python Basics #2: Variables, basic types, and type hints

4 min read

We continue from the project we made in #1 Getting started and uv setup. The topic of this post is variables, basic types, and type hints.

Python is a dynamically typed language. You don’t have to write types on variables. Yet modern Python writes types from the start — that’s now the standard. This post explains why and how.

Variables — no separate declaration #

The biggest difference from other languages. There’s no let, var, or int x keyword. Just assign a value to a name and that’s a variable.

Variables
name = "커티스"
age = 30
height = 175.5
is_admin = True

The naming convention is snake_case. It’s different from JavaScript’s camelCase, which feels strange at first. Constants use uppercase + underscore (MAX_RETRY = 3).

Basic types — 4 + 1 #

Pin down the five most-used:

Basic types
i: int = 42
f: float = 3.14
s: str = "hello"
b: bool = True
n: None = None

The : int part is a type hint. After the variable name, write a colon and a type. It doesn’t affect behavior, but tools (IDEs/type checkers) read it for validation and autocomplete.

Why annotate types in a dynamically typed language? #

Type hints are simply ignored at runtime. The following code runs fine.

Ignored at runtime
x: int = "this is a string"
print(x)   # this is a string

Reasons to write them anyway:

  1. Editor autocomplete and go-to-definition become accurate — VS Code and PyCharm both read types
  2. Type checkers (mypy / pyright / Pyrefly) catch problems at “compile” time
  3. Acts as documentation when others read your code — function signatures alone tell what’s taken and returned
  4. Refactor safety net — change a function signature and you see what breaks

Old Python code lacks types. The modern Python convention is to add them wherever possible in new code.

Type hints — variables, functions, collections #

Variables #

Variable annotations
count: int = 0
name: str
name = "curtis"     # declare type first, assign value later

name: str is also valid — type only, no value. Common in class field declarations.

Functions #

Function signatures
def add(a: int, b: int) -> int:
    return a + b

def greet(name: str) -> None:
    print(f"hi, {name}")

After parameters: : type; for return: -> type. If nothing returns, -> None. Functions without these leave the IDE guessing at the return type, which dims autocomplete for callers as well.

Collections — built-in generics #

For collections like lists and dicts, annotate element types too.

Collection types
nums: list[int] = [1, 2, 3]
names: list[str] = ["a", "b"]
ages: dict[str, int] = {"curtis": 30, "smith": 25}
unique: set[str] = {"a", "b"}
point: tuple[float, float] = (1.0, 2.0)

The syntax of putting [] directly on built-in types like list[int] is supported from Python 3.9. Before that, it was from typing import List then List[int]. You’ll still see it in old code, but new code unifies on the built-in form.

Old style (3.8 and below) — don't use in new code
from typing import List, Dict
nums: List[int] = [1, 2, 3]
ages: Dict[str, int] = {"curtis": 30}

None and option types — int | None #

For fields where a value may or may not exist.

Option type — modern syntax
def find_user(id: int) -> str | None:
    if id == 1:
        return "curtis"
    return None

str | None is the union shorthand supported from Python 3.10. Before that you had to import Optional[str] or Union[str, None] from typing.

Old style — don't use in new code
from typing import Optional, Union

def find_user(id: int) -> Optional[str]: ...
def parse(value: str) -> Union[int, float, None]: ...

New code always uses |. Short, no extra import.

Combining multiple types
def parse(value: str) -> int | float | None:
    try:
        return int(value)
    except ValueError:
        try:
            return float(value)
        except ValueError:
            return None

Numbers — int and float #

Python’s int is arbitrary precision. No 64-bit limit.

Big integer
big = 10 ** 100
print(big)
# 10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

float is IEEE 754 double. The same floating-point gotchas as other languages apply.

Floating point
print(0.1 + 0.2)       # 0.30000000000000004
print(0.1 + 0.2 == 0.3)  # False

For finance or exact decimals, use decimal.Decimal.

Exact decimals
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))  # 0.3

Small tip — numeric literals #

You can insert underscores for readability (3.6+).

Readability
ONE_MILLION = 1_000_000
HEX = 0xFF_FF
BIN = 0b_1010_1010

Strings — str and f-strings #

There’s no difference between single and double quotes. Use one consistently per project (usually ").

String basics
s1 = "hello"
s2 = 'world'
s3 = """여러 줄
문자열"""

For interpolation, use f-strings.

f-string
name = "curtis"
age = 30

print(f"hi, {name}! you are {age} years old.")
print(f"next year you'll be {age + 1}.")
print(f"{name = }")    # name = 'curtis'  (handy for debugging)

The f"{var = }" form prints the variable name and the value together. Used a lot for debug prints.

t-strings — Python 3.14’s new syntax #

PEP 750 added t-strings in Python 3.14. They look like f-strings, but interpolation does not happen immediately; the result is a Template object instead.

t-string (3.14+)
from string.templatelib import Template

name = "curtis"
tpl: Template = t"hi, {name}!"
# Template object. Not yet joined into a string.

# Pass to a function that handles it safely
def render_html(template: Template) -> str: ...
html = render_html(t"<a href='{user_url}'>{user_name}</a>")

When to use? Places where user input must be handled safely — SQL, HTML, shell. With f-strings, the interpolated result is a string immediately, so it’s easy to forget to escape; with t-strings, the library carries the escape responsibility. At the entry-level, “knowing f-strings is enough”; just recognize t-strings when you encounter them in library code.

bool and truthiness #

There are two values, True and False, and bool is a subtype of int.

bool is int
print(True + True)     # 2
print(isinstance(True, int))  # True

Interesting, but mixing int where bool is expected isn’t recommended. Use True/False explicitly.

Truthy / falsy #

Empty containers and 0 are false; everything else is true.

falsy values
if not []:    print("empty list is falsy")
if not "":    print("empty str is falsy")
if not 0:     print("zero is falsy")
if not None:  print("None is falsy")

Expressions like if not items: are common. Shorter than JavaScript’s if (!arr.length).

Type conversion — explicit #

Python performs almost no implicit type coercion. Adding an int and a string raises an error.

Explicit conversion required
n = 42
s = "answer: " + str(n)   # must convert with str(n)
# "answer: " + n  → TypeError

age = int(input("나이: "))  # input returns str → convert to int

Common conversions:

Common conversions
str(42)        # '42'
int("42")      # 42
int("42", 16)  # 66 (interpret as hex)
float("3.14")  # 3.14
bool(0)        # False
bool("any")    # True (only the empty string is False)
list("abc")    # ['a', 'b', 'c']

Type aliases — the type keyword #

When the same type shape repeats, name it.

Type alias (3.12+)
type UserId = int
type UserName = str
type UserMap = dict[UserId, UserName]

def get_user(id: UserId) -> UserName | None: ...

The type syntax was added in Python 3.12. Before that, it was written as:

Old style
UserId = int    # just a variable — works as an alias
# or
from typing import TypeAlias
UserId: TypeAlias = int

New code unifies on type.

Type checkers — mypy / pyright #

Since type hints aren’t validated at runtime, a separate tool does static checking. Two camps:

  • mypy — the oldest standard. Python-camp tool. uv add --dev mypy
  • pyright / Pylance — Microsoft’s. Fast, with strong VS Code integration. uv add --dev pyright
  • Pyrefly — Meta’s newest (still beta as of 2026). Faster next-gen contender

For a new project, starting with pyright is reasonable. With the Python extension in VS Code, Pylance kicks in automatically and you barely need any configuration.

Add pyright
uv add --dev pyright
uv run pyright .

A small example to see it in action:

check.py
def add(a: int, b: int) -> int:
    return a + b

result = add("hello", 1)   # ✗ str is not int
$ uv run pyright check.py
check.py:4:18 - error: Argument of type "Literal['hello']" cannot be assigned to parameter "a" of type "int"

Caught before runtime. It can feel like nagging at first, but after a month you’ll wonder how you lived without it.

Wrap-up #

What this post covered:

  • Python has no separate variable declaration — assignment = declaration
  • Five basic types: int, float, str, bool, None
  • Type hints are ignored at runtime, yet always writing them is the modern Python convention
  • Use built-in generics for collections: list[int], dict[str, int], tuple[int, str]
  • Options are T | None (3.10+); old Optional isn’t used in new code
  • f-string f"{var}" and debug-friendly f"{var = }"; t-strings for safe interpolation
  • int is arbitrary-precision, float is IEEE 754; use Decimal when accuracy matters
  • Type aliases are type Name = ... (3.12+)
  • Static type validation with mypy or pyright

In the next post (#3 Control flow) we cover flow control — if, while, for, and the match-case added in 3.10. The key is how it differs from switch in other languages.

X