You have two objectives
- keep the code - DRYand
 
- use bind variables 
If you have only a very simple condition (as in your example, use filter or use no filter)  you my use IF statement to open different cursors for both cases.
IF salary_from is null THEN
  OPEN v_cursor FOR SELECT LAST_NAME, SALARY FROM hr.employees;
ELSE
  OPEN v_cursor FOR SELECT LAST_NAME, SALARY FROM hr.employees WHERE SALARY >= salary_from;
END IF;
Note that you should be carefull with the OR solution using the predicate SALARY >= salary_from OR salary_from  is NULL
Why?  You use one query for two very different scenarios. The cursor can returns either all data or very limited data, that could require a different  access method (e.g. index access vs full table scan). So you may and with suboptimal plan in one case.
The problem with the above approach is that it does not scale. If you have four optional criteria you will need 16-fold IF with a highly redundant code.
What is the solution to keep the above objectives valid?
Use dynamic SQL, but do not concatenate the criteria values such as
 SELECT LAST_NAME, SALARY FROM hr.employees WHERE salary >= 1000 and salary <= 10000
This will invalidate the bind variable objective!
To keep with the example with optional parameter salary_from and salary_to you want to use
open v_cursor for v_sql using salary_from, salary_to;
But this requires, that both bind variables must be defined in the query text - what should be done in case you have only salary_from?
Opening the cursor for this query
SELECT LAST_NAME, SALARY FROM hr.employees WHERE SALARY >= :salary_from 
will lead to error
ORA-01006: bind variable does not exist
The trick is to use dummy predicates returning always true, but containing a bind variable (that will be ignored).
So if you have only salary_from as a filter you will create following dynamic SQL
SELECT LAST_NAME, SALARY FROM hr.employees WHERE SALARY >= :salary_from AND  (1=1 or SALARY <= :salary_to)
It contains both bind variables and the optimizer (with the *shortcut evaluation) will simplify it to the required
SELECT LAST_NAME, SALARY FROM hr.employees WHERE SALARY >= :salary_from
So the relevant code for the salary filter example would be
IF salary_from is NOT null THEN
  v_sql := 'SELECT LAST_NAME, SALARY FROM hr.employees WHERE SALARY >= :salary_from';
ELSE
  v_sql := 'SELECT LAST_NAME, SALARY FROM hr.employees WHERE (1 = 1 or SALARY >= :salary_from)';  
END IF;
IF salary_to is NOT null THEN
  v_sql := v_sql ||' AND  SALARY <= :salary_to';
ELSE
  v_sql := v_sql ||' AND  (1=1 or SALARY <= :salary_to)';  
END IF;
open v_cursor for v_sql using salary_from, salary_to;
Below the overview of the generated SQL for the four cases
-- no filter
SELECT LAST_NAME, SALARY FROM hr.employees WHERE (1 = 1 or SALARY >= :salary_from) AND  (1=1 or SALARY <= :salary_to)
-- salary_from 
SELECT LAST_NAME, SALARY FROM hr.employees WHERE SALARY >= :salary_from AND  (1=1 or SALARY <= :salary_to)
-- salary from, to
SELECT LAST_NAME, SALARY FROM hr.employees WHERE SALARY >= :salary_from AND  SALARY <= :salary_to
-- salary_to
SELECT LAST_NAME, SALARY FROM hr.employees WHERE (1 = 1 or SALARY >= :salary_from) AND  SALARY <= :salary_to
Similar topics with credit to this idea: here, here and here